From e59078caa8d3bd2adc00065c2cdb43bb55e73126 Mon Sep 17 00:00:00 2001 From: Arnaud Mergey Date: Fri, 3 Jul 2026 12:06:04 +0200 Subject: [PATCH 1/7] Add SEMARCHY dialect and scaffold TypeScript OSI<>Semarchy converter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register Semarchy as a new spoke in the hub-and-spoke converter model. Spec change (SEMARCHY dialect), synced across all artifacts: - core-spec/osi-schema.json: add SEMARCHY to the Dialect enum - core-spec/spec.md, core-spec/spec.yaml: document the dialect - validation/validate.py: map SEMARCHY (skip sqlglot SQL validation) - python/src/osi/models.py: add SEMARCHY to OSIDialect - converters/index.md: add SEMARCHY to Supported Vendors Both examples still validate against the schema. New converter converters/semarchy (TypeScript, Node >=18, ESM) — the repo's first non-Python/Java converter, mirroring osi-dbt's CLI shape: - commander CLI: osi-to-semarchy / semarchy-to-osi subcommands - OSI types mirroring python/src/osi/models.py - dialect fallback chain (SEMARCHY -> ANSI_SQL -> warn), fully implemented - ajv-based schema validation for OSI and Semarchy output (best-effort) - vitest tests (dialect fallback green; roundtrip todo pending schema) The per-construct import/export mapping is stubbed and marked TODO(schema): it needs the Semarchy semantic-model JSON Schema, which must be dropped into converters/semarchy/schemas/. typecheck, tests, and build all pass on Node 20. Co-Authored-By: Claude Opus 4.8 --- converters/index.md | 1 + converters/semarchy/.gitignore | 3 + converters/semarchy/README.md | 65 + converters/semarchy/package-lock.json | 2524 +++++++++++++++++++ converters/semarchy/package.json | 37 + converters/semarchy/schemas/README.md | 14 + converters/semarchy/src/cli.ts | 62 + converters/semarchy/src/dialect.ts | 50 + converters/semarchy/src/index.ts | 15 + converters/semarchy/src/osi-to-semarchy.ts | 73 + converters/semarchy/src/osi-types.ts | 123 + converters/semarchy/src/semarchy-to-osi.ts | 40 + converters/semarchy/src/semarchy-types.ts | 19 + converters/semarchy/src/validation.ts | 56 + converters/semarchy/tests/dialect.test.ts | 46 + converters/semarchy/tests/roundtrip.test.ts | 44 + converters/semarchy/tsconfig.json | 19 + converters/semarchy/vitest.config.ts | 8 + core-spec/osi-schema.json | 2 +- core-spec/spec.md | 1 + core-spec/spec.yaml | 1 + python/src/osi/models.py | 1 + validation/validate.py | 3 +- 23 files changed, 3205 insertions(+), 2 deletions(-) create mode 100644 converters/semarchy/.gitignore create mode 100644 converters/semarchy/README.md create mode 100644 converters/semarchy/package-lock.json create mode 100644 converters/semarchy/package.json create mode 100644 converters/semarchy/schemas/README.md create mode 100644 converters/semarchy/src/cli.ts create mode 100644 converters/semarchy/src/dialect.ts create mode 100644 converters/semarchy/src/index.ts create mode 100644 converters/semarchy/src/osi-to-semarchy.ts create mode 100644 converters/semarchy/src/osi-types.ts create mode 100644 converters/semarchy/src/semarchy-to-osi.ts create mode 100644 converters/semarchy/src/semarchy-types.ts create mode 100644 converters/semarchy/src/validation.ts create mode 100644 converters/semarchy/tests/dialect.test.ts create mode 100644 converters/semarchy/tests/roundtrip.test.ts create mode 100644 converters/semarchy/tsconfig.json create mode 100644 converters/semarchy/vitest.config.ts diff --git a/converters/index.md b/converters/index.md index 6477291b..d9486417 100644 --- a/converters/index.md +++ b/converters/index.md @@ -54,6 +54,7 @@ The OSI specification currently defines extensions for the following vendors: | `SALESFORCE` | Salesforce / Tableau semantic layer | | `DBT` | dbt semantic models | | `DATABRICKS` | Databricks semantic layer | +| `SEMARCHY` | Semarchy semantic model | Each vendor may define custom extensions (via the `custom_extensions` field in the OSI spec) to carry vendor-specific metadata that does not have an equivalent in the core specification. diff --git a/converters/semarchy/.gitignore b/converters/semarchy/.gitignore new file mode 100644 index 00000000..f4e2c6d6 --- /dev/null +++ b/converters/semarchy/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/converters/semarchy/README.md b/converters/semarchy/README.md new file mode 100644 index 00000000..33111652 --- /dev/null +++ b/converters/semarchy/README.md @@ -0,0 +1,65 @@ +# osi-semarchy + +Bidirectional converter between [OSI](../../core-spec/spec.md) and the **Semarchy** +semantic model. + +- **Export** — OSI → Semarchy (`osi-to-semarchy`) +- **Import** — Semarchy → OSI (`semarchy-to-osi`) + +Unlike the other reference converters (Python / Java), this one is written in +**TypeScript** (Node ≥ 18, ESM). It is offline: it only reads and writes files. + +## Status + +> ⚠️ **Scaffold — mapping incomplete.** The project structure, CLI, OSI types, +> the dialect-fallback chain, and schema-validation plumbing are in place and +> tested. The per-construct mapping is stubbed pending the **Semarchy semantic +> model JSON Schema** (see [`schemas/README.md`](schemas/README.md)). Every +> incomplete step is marked `TODO(schema)` in the source. + +## Install & build + +```bash +cd converters/semarchy +npm install +npm run build # emits dist/ (library + CLI) +npm test # vitest +npm run typecheck +``` + +## CLI + +```bash +# OSI (YAML) -> Semarchy (JSON) +osi-semarchy osi-to-semarchy -i model.yaml -o model.semarchy.json + +# Semarchy (JSON) -> OSI (YAML) +osi-semarchy semarchy-to-osi -i model.semarchy.json -o model.yaml +``` + +## Library + +```ts +import { osiDocumentToSemarchy, semarchyToOsi } from "osi-semarchy"; +``` + +## Design notes + +- **Dialect selection** uses the OSI fallback chain: prefer the `SEMARCHY` + dialect, fall back to `ANSI_SQL`, warn (or error) if neither is present. See + `src/dialect.ts`. `SEMARCHY` was added to the core-spec `Dialect` enum for + this converter. +- **Vendor name** for custom extensions is `SEMARCHY`. Extensions for *other* + vendors are never discarded on import — they roundtrip untouched. +- **Roundtrip fidelity**: anything with no OSI core equivalent is preserved in + `custom_extensions` (`vendor_name: "SEMARCHY"`); `ai_context` a target can't + represent goes into a `COMMON` extension. +- **Validation**: OSI output is checked against `schemas/osi-schema.json` and + Semarchy output against `schemas/semarchy-semantic-model-schema.json` when + those files are present (best-effort; skipped with a warning otherwise). + +## Limitations + +- Mapping is not yet implemented (see **Status**); requires the Semarchy schema. +- Metrics using dialect-specific SQL absent from both `SEMARCHY` and `ANSI_SQL` + are skipped with a warning rather than emitted. diff --git a/converters/semarchy/package-lock.json b/converters/semarchy/package-lock.json new file mode 100644 index 00000000..77116d43 --- /dev/null +++ b/converters/semarchy/package-lock.json @@ -0,0 +1,2524 @@ +{ + "name": "osi-semarchy", + "version": "0.2.0-dev0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "osi-semarchy", + "version": "0.2.0-dev0", + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.17.1", + "commander": "^12.1.0", + "yaml": "^2.5.1" + }, + "bin": { + "osi-semarchy": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^22.7.4", + "tsup": "^8.3.0", + "typescript": "^5.6.2", + "vitest": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/converters/semarchy/package.json b/converters/semarchy/package.json new file mode 100644 index 00000000..2a773593 --- /dev/null +++ b/converters/semarchy/package.json @@ -0,0 +1,37 @@ +{ + "name": "osi-semarchy", + "version": "0.2.0-dev0", + "description": "Semarchy semantic model <> OSI converter (bidirectional)", + "license": "Apache-2.0", + "type": "module", + "bin": { + "osi-semarchy": "./dist/cli.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "schemas" + ], + "scripts": { + "build": "tsup src/index.ts src/cli.ts --format esm --dts --clean", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "lint": "tsc --noEmit" + }, + "dependencies": { + "ajv": "^8.17.1", + "commander": "^12.1.0", + "yaml": "^2.5.1" + }, + "devDependencies": { + "@types/node": "^22.7.4", + "tsup": "^8.3.0", + "typescript": "^5.6.2", + "vitest": "^2.1.2" + }, + "engines": { + "node": ">=18" + } +} diff --git a/converters/semarchy/schemas/README.md b/converters/semarchy/schemas/README.md new file mode 100644 index 00000000..c6f1c489 --- /dev/null +++ b/converters/semarchy/schemas/README.md @@ -0,0 +1,14 @@ +# Schemas + +This directory bundles the JSON Schemas the converter validates against. Both +files are fetched/copied manually and are **required for full validation** (the +converter degrades to skip-with-warning if they are absent). + +| File | Purpose | How to obtain | +|------|---------|---------------| +| `osi-schema.json` | Validate OSI output on import (Semarchy → OSI). | Copy from `core-spec/osi-schema.json`. | +| `semarchy-semantic-model-schema.json` | Validate Semarchy output on export (OSI → Semarchy). | **PENDING** — provide the Semarchy semantic-model JSON Schema. | + +Once `semarchy-semantic-model-schema.json` is present, replace the placeholder +types in `src/semarchy-types.ts` with the real constructs and complete the +mapping in `src/osi-to-semarchy.ts` and `src/semarchy-to-osi.ts`. diff --git a/converters/semarchy/src/cli.ts b/converters/semarchy/src/cli.ts new file mode 100644 index 00000000..50b5b84e --- /dev/null +++ b/converters/semarchy/src/cli.ts @@ -0,0 +1,62 @@ +#!/usr/bin/env node +/** + * osi-semarchy CLI. + * + * osi-semarchy osi-to-semarchy -i model.yaml -o model.semarchy.json + * osi-semarchy semarchy-to-osi -i model.semarchy.json -o model.yaml + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { Command } from "commander"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +import { osiDocumentToSemarchy } from "./osi-to-semarchy.js"; +import { semarchyToOsi } from "./semarchy-to-osi.js"; +import { validateOsi, validateSemarchy } from "./validation.js"; +import type { OSIDocument } from "./osi-types.js"; +import type { SemarchyModel } from "./semarchy-types.js"; + +function emitWarnings(warnings: string[]): void { + for (const w of warnings) process.stderr.write(`warning: ${w}\n`); +} + +function reportValidation(label: string, outcome: ReturnType): void { + if (outcome.skipped) { + process.stderr.write(`warning: ${label} ${outcome.errors[0]}\n`); + } else if (!outcome.valid) { + process.stderr.write(`error: ${label} validation failed:\n`); + for (const e of outcome.errors) process.stderr.write(` - ${e}\n`); + process.exitCode = 1; + } +} + +const program = new Command(); +program + .name("osi-semarchy") + .description("Bidirectional OSI <> Semarchy semantic model converter"); + +program + .command("osi-to-semarchy") + .description("Convert an OSI model (YAML) to a Semarchy model (JSON)") + .requiredOption("-i, --input ", "input OSI YAML file") + .requiredOption("-o, --output ", "output Semarchy JSON file") + .action((opts: { input: string; output: string }) => { + const doc = parseYaml(readFileSync(opts.input, "utf8")) as OSIDocument; + const { model, warnings } = osiDocumentToSemarchy(doc); + emitWarnings(warnings); + reportValidation("semarchy output", validateSemarchy(model)); + writeFileSync(opts.output, JSON.stringify(model, null, 2) + "\n"); + }); + +program + .command("semarchy-to-osi") + .description("Convert a Semarchy model (JSON) to an OSI model (YAML)") + .requiredOption("-i, --input ", "input Semarchy JSON file") + .requiredOption("-o, --output ", "output OSI YAML file") + .action((opts: { input: string; output: string }) => { + const model = JSON.parse(readFileSync(opts.input, "utf8")) as SemarchyModel; + const { document, warnings } = semarchyToOsi(model); + emitWarnings(warnings); + reportValidation("osi output", validateOsi(document)); + writeFileSync(opts.output, stringifyYaml(document)); + }); + +program.parse(); diff --git a/converters/semarchy/src/dialect.ts b/converters/semarchy/src/dialect.ts new file mode 100644 index 00000000..73e3ac42 --- /dev/null +++ b/converters/semarchy/src/dialect.ts @@ -0,0 +1,50 @@ +/** + * Dialect selection with the OSI fallback chain. + * + * Per the converter guide: prefer the vendor dialect, fall back to ANSI_SQL, + * and warn (or error) if neither is present. Never silently drop an expression. + */ +import type { OSIDialect, OSIExpression } from "./osi-types.js"; + +export const SEMARCHY_DIALECT: OSIDialect = "SEMARCHY"; +export const ANSI_DIALECT: OSIDialect = "ANSI_SQL"; + +export interface DialectSelection { + dialect: OSIDialect; + expression: string; +} + +/** + * Select the best expression for the Semarchy target using the fallback chain + * SEMARCHY -> ANSI_SQL. Returns `undefined` and pushes a warning if neither is + * available; callers decide whether that is a warning or a hard error. + */ +export function selectExpression( + expr: OSIExpression | undefined, + context: string, + warnings: string[], +): DialectSelection | undefined { + if (!expr || expr.dialects.length === 0) { + warnings.push(`[dialect] ${context}: no expression defined`); + return undefined; + } + + const semarchy = expr.dialects.find((d) => d.dialect === SEMARCHY_DIALECT); + if (semarchy) { + return { dialect: SEMARCHY_DIALECT, expression: semarchy.expression }; + } + + const ansi = expr.dialects.find((d) => d.dialect === ANSI_DIALECT); + if (ansi) { + warnings.push( + `[dialect] ${context}: no SEMARCHY dialect, falling back to ANSI_SQL`, + ); + return { dialect: ANSI_DIALECT, expression: ansi.expression }; + } + + warnings.push( + `[dialect] ${context}: neither SEMARCHY nor ANSI_SQL present ` + + `(have: ${expr.dialects.map((d) => d.dialect).join(", ")})`, + ); + return undefined; +} diff --git a/converters/semarchy/src/index.ts b/converters/semarchy/src/index.ts new file mode 100644 index 00000000..1ad8b842 --- /dev/null +++ b/converters/semarchy/src/index.ts @@ -0,0 +1,15 @@ +/** Public library API for the OSI <> Semarchy converter. */ +export * from "./osi-types.js"; +export * from "./semarchy-types.js"; +export { osiToSemarchy, osiDocumentToSemarchy } from "./osi-to-semarchy.js"; +export type { ExportResult } from "./osi-to-semarchy.js"; +export { semarchyToOsi } from "./semarchy-to-osi.js"; +export type { ImportResult } from "./semarchy-to-osi.js"; +export { selectExpression } from "./dialect.js"; +export { + validateSemarchy, + validateOsi, + SEMARCHY_SCHEMA_PATH, + OSI_SCHEMA_PATH, +} from "./validation.js"; +export type { ValidationOutcome } from "./validation.js"; diff --git a/converters/semarchy/src/osi-to-semarchy.ts b/converters/semarchy/src/osi-to-semarchy.ts new file mode 100644 index 00000000..02c1c0d8 --- /dev/null +++ b/converters/semarchy/src/osi-to-semarchy.ts @@ -0,0 +1,73 @@ +/** + * Export: OSI -> Semarchy. + * + * Follows the 9-step export procedure from the converter guide. The dataset / + * field / relationship / metric traversal and the dialect-fallback plumbing are + * in place; the per-construct emission into Semarchy shapes is marked TODO until + * the Semarchy JSON Schema lands. + */ +import { selectExpression } from "./dialect.js"; +import type { OSIDocument, OSISemanticModel } from "./osi-types.js"; +import type { SemarchyModel } from "./semarchy-types.js"; + +export interface ExportResult { + model: SemarchyModel; + warnings: string[]; +} + +const SEMARCHY_VENDOR = "SEMARCHY"; + +/** Convert a single OSI semantic model to a Semarchy model. */ +export function osiToSemarchy( + osi: OSISemanticModel, + warnings: string[] = [], +): ExportResult { + const model: SemarchyModel = { + name: osi.name, + description: osi.description, + }; + + // Step 3-4: datasets and their fields (dialect fallback: SEMARCHY -> ANSI_SQL). + for (const dataset of osi.datasets ?? []) { + for (const field of dataset.fields ?? []) { + selectExpression( + field.expression, + `dataset ${dataset.name}, field ${field.name}`, + warnings, + ); + // TODO(schema): emit Semarchy attribute from the selected expression. + } + // TODO(schema): emit Semarchy entity (source, primary_key, unique_keys). + } + + // Step 5: relationships (preserve composite-key column ordering). + for (const _rel of osi.relationships ?? []) { + // TODO(schema): emit Semarchy relationship (from/to, from_columns/to_columns). + } + + // Step 6: metrics (same dialect logic; resolve cross-dataset references). + for (const metric of osi.metrics ?? []) { + selectExpression(metric.expression, `metric ${metric.name}`, warnings); + // TODO(schema): emit Semarchy measure. + } + + // Step 7: apply SEMARCHY custom_extensions; every other vendor's entries are + // preserved on the import side, never here. + for (const ext of osi.custom_extensions ?? []) { + if (ext.vendor_name === SEMARCHY_VENDOR) { + // TODO(schema): merge JSON.parse(ext.data) into the Semarchy model. + } + } + + // Step 8: ai_context -> Semarchy annotations. TODO(schema). + + return { model, warnings }; +} + +/** Convenience wrapper over a full OSI document (converts the first model). */ +export function osiDocumentToSemarchy(doc: OSIDocument): ExportResult { + if (!doc.semantic_model?.length) { + throw new Error("OSI document has no semantic_model entries"); + } + return osiToSemarchy(doc.semantic_model[0]!); +} diff --git a/converters/semarchy/src/osi-types.ts b/converters/semarchy/src/osi-types.ts new file mode 100644 index 00000000..4e547904 --- /dev/null +++ b/converters/semarchy/src/osi-types.ts @@ -0,0 +1,123 @@ +/** + * TypeScript types for the OSI core model. + * + * Mirrors `python/src/osi/models.py` (the canonical Pydantic types) and + * `core-spec/osi-schema.json`. Keep in sync when the spec changes. + */ + +/** Supported SQL and expression language dialects (core-spec Dialect enum). */ +export type OSIDialect = + | "ANSI_SQL" + | "SNOWFLAKE" + | "MDX" + | "TABLEAU" + | "DATABRICKS" + | "MAQL" + | "SEMARCHY"; + +/** + * Vendor name for custom extensions. The spec accepts any string, but these are + * the well-known values. Use "SEMARCHY" for this converter and "COMMON" for + * cross-vendor metadata (e.g. ai_context that a vendor cannot represent). + */ +export type OSIVendor = + | "COMMON" + | "SNOWFLAKE" + | "SALESFORCE" + | "DBT" + | "DATABRICKS" + | "GOODDATA" + | "SEMARCHY" + | (string & {}); + +export interface OSIAIContextObject { + instructions?: string; + synonyms?: string[]; + examples?: string[]; +} + +/** ai_context is either a bare string or a structured object. */ +export type OSIAIContext = string | OSIAIContextObject; + +/** Vendor-specific metadata carried as a serialized JSON string in `data`. */ +export interface OSICustomExtension { + vendor_name: OSIVendor; + /** JSON string containing vendor-specific data. */ + data: string; +} + +export interface OSIDialectExpression { + dialect: OSIDialect; + expression: string; +} + +export interface OSIExpression { + dialects: OSIDialectExpression[]; +} + +export interface OSIDimension { + is_time?: boolean; +} + +export interface OSIField { + name: string; + expression: OSIExpression; + dimension?: OSIDimension; + label?: string; + description?: string; + ai_context?: OSIAIContext; + custom_extensions?: OSICustomExtension[]; +} + +export interface OSIDataset { + name: string; + /** `database.schema.table` style source reference. */ + source: string; + primary_key?: string[]; + unique_keys?: string[][]; + description?: string; + ai_context?: OSIAIContext; + fields?: OSIField[]; + custom_extensions?: OSICustomExtension[]; +} + +export interface OSIRelationship { + name: string; + /** Many-side dataset (serialized as `from` in YAML/JSON). */ + from: string; + /** One-side dataset. */ + to: string; + /** Positionally corresponds to `to_columns`; preserve order. */ + from_columns: string[]; + to_columns: string[]; + ai_context?: OSIAIContext; + custom_extensions?: OSICustomExtension[]; +} + +export interface OSIMetric { + name: string; + expression: OSIExpression; + description?: string; + ai_context?: OSIAIContext; + custom_extensions?: OSICustomExtension[]; +} + +export interface OSISemanticModel { + name: string; + description?: string; + ai_context?: OSIAIContext; + datasets: OSIDataset[]; + relationships?: OSIRelationship[]; + metrics?: OSIMetric[]; + custom_extensions?: OSICustomExtension[]; +} + +export interface OSIDocument { + version: string; + dialects?: OSIDialect[]; + vendors?: OSIVendor[]; + semantic_model: OSISemanticModel[]; +} + +/** The OSI spec version this converter targets. */ +export const OSI_VERSION = "0.2.0.dev0"; diff --git a/converters/semarchy/src/semarchy-to-osi.ts b/converters/semarchy/src/semarchy-to-osi.ts new file mode 100644 index 00000000..a2d375e4 --- /dev/null +++ b/converters/semarchy/src/semarchy-to-osi.ts @@ -0,0 +1,40 @@ +/** + * Import: Semarchy -> OSI. + * + * Produces an OSI document that must validate against `core-spec/osi-schema.json`. + * Semarchy metadata with no OSI core equivalent must be captured into + * `custom_extensions` (vendor_name "SEMARCHY") so a Semarchy -> OSI -> Semarchy + * roundtrip stays faithful. + */ +import { OSI_VERSION } from "./osi-types.js"; +import type { OSIDocument, OSISemanticModel } from "./osi-types.js"; +import type { SemarchyModel } from "./semarchy-types.js"; + +export interface ImportResult { + document: OSIDocument; + warnings: string[]; +} + +/** Convert a Semarchy model into an OSI document. */ +export function semarchyToOsi( + semarchy: SemarchyModel, + warnings: string[] = [], +): ImportResult { + const semanticModel: OSISemanticModel = { + name: semarchy.name, + description: semarchy.description, + datasets: [], + }; + + // TODO(schema): map Semarchy entities -> datasets, attributes -> fields + // (emit expression.dialects with dialect "SEMARCHY"), relationships, + // measures -> metrics. Anything without an OSI equivalent goes into + // custom_extensions with vendor_name "SEMARCHY". + + const document: OSIDocument = { + version: OSI_VERSION, + semantic_model: [semanticModel], + }; + + return { document, warnings }; +} diff --git a/converters/semarchy/src/semarchy-types.ts b/converters/semarchy/src/semarchy-types.ts new file mode 100644 index 00000000..bea07adc --- /dev/null +++ b/converters/semarchy/src/semarchy-types.ts @@ -0,0 +1,19 @@ +/** + * TypeScript types for the Semarchy semantic model. + * + * TODO(schema): These are PLACEHOLDER shapes. The real definitions must be + * derived from the Semarchy semantic-model JSON Schema, which should be dropped + * into `converters/semarchy/schemas/semarchy-semantic-model-schema.json` (see + * README). Once the schema is available: + * 1. Replace these interfaces with the real Semarchy constructs. + * 2. Wire the schema into `validateSemarchy()` in `semarchy-schema.ts`. + * 3. Complete the mapping in `osi-to-semarchy.ts` / `semarchy-to-osi.ts`. + */ + +/** Placeholder root object of a Semarchy semantic model. */ +export interface SemarchyModel { + name: string; + description?: string; + // TODO(schema): entities, attributes, relationships, measures, etc. + [key: string]: unknown; +} diff --git a/converters/semarchy/src/validation.ts b/converters/semarchy/src/validation.ts new file mode 100644 index 00000000..e360fe12 --- /dev/null +++ b/converters/semarchy/src/validation.ts @@ -0,0 +1,56 @@ +/** + * Schema validation helpers. + * + * - Semarchy output is validated against the Semarchy JSON Schema (guide step 9), + * once it is present under `schemas/`. + * - OSI output is validated against `osi-schema.json` (guide step 1 analogue). + * + * Both are best-effort: if a schema file is not found, validation is skipped + * with a warning rather than failing, so the scaffold is usable before the + * Semarchy schema is dropped in. + */ +import { existsSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import Ajv from "ajv"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SCHEMAS_DIR = resolve(__dirname, "..", "schemas"); + +export const SEMARCHY_SCHEMA_PATH = resolve( + SCHEMAS_DIR, + "semarchy-semantic-model-schema.json", +); +export const OSI_SCHEMA_PATH = resolve(SCHEMAS_DIR, "osi-schema.json"); + +export interface ValidationOutcome { + valid: boolean; + errors: string[]; + skipped: boolean; +} + +function validateAgainst(schemaPath: string, data: unknown): ValidationOutcome { + if (!existsSync(schemaPath)) { + return { + valid: true, + skipped: true, + errors: [`schema not found, validation skipped: ${schemaPath}`], + }; + } + const schema = JSON.parse(readFileSync(schemaPath, "utf8")); + const ajv = new Ajv({ allErrors: true, strict: false }); + const validate = ajv.compile(schema); + const valid = validate(data) as boolean; + const errors = (validate.errors ?? []).map( + (e) => `${e.instancePath || "/"} ${e.message ?? ""}`.trim(), + ); + return { valid, skipped: false, errors }; +} + +export function validateSemarchy(model: unknown): ValidationOutcome { + return validateAgainst(SEMARCHY_SCHEMA_PATH, model); +} + +export function validateOsi(document: unknown): ValidationOutcome { + return validateAgainst(OSI_SCHEMA_PATH, document); +} diff --git a/converters/semarchy/tests/dialect.test.ts b/converters/semarchy/tests/dialect.test.ts new file mode 100644 index 00000000..b5e63567 --- /dev/null +++ b/converters/semarchy/tests/dialect.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { selectExpression } from "../src/dialect.js"; +import type { OSIExpression } from "../src/osi-types.js"; + +describe("selectExpression (dialect fallback chain)", () => { + it("prefers the SEMARCHY dialect when present", () => { + const expr: OSIExpression = { + dialects: [ + { dialect: "ANSI_SQL", expression: "a + b" }, + { dialect: "SEMARCHY", expression: "a ++ b" }, + ], + }; + const warnings: string[] = []; + const sel = selectExpression(expr, "field x", warnings); + expect(sel).toEqual({ dialect: "SEMARCHY", expression: "a ++ b" }); + expect(warnings).toHaveLength(0); + }); + + it("falls back to ANSI_SQL and warns when SEMARCHY is missing", () => { + const expr: OSIExpression = { + dialects: [{ dialect: "ANSI_SQL", expression: "a + b" }], + }; + const warnings: string[] = []; + const sel = selectExpression(expr, "field x", warnings); + expect(sel).toEqual({ dialect: "ANSI_SQL", expression: "a + b" }); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("falling back to ANSI_SQL"); + }); + + it("returns undefined and warns when neither SEMARCHY nor ANSI_SQL is present", () => { + const expr: OSIExpression = { + dialects: [{ dialect: "SNOWFLAKE", expression: "a + b" }], + }; + const warnings: string[] = []; + const sel = selectExpression(expr, "metric m", warnings); + expect(sel).toBeUndefined(); + expect(warnings[0]).toContain("neither SEMARCHY nor ANSI_SQL"); + }); + + it("warns when there is no expression at all", () => { + const warnings: string[] = []; + expect(selectExpression(undefined, "field y", warnings)).toBeUndefined(); + expect(selectExpression({ dialects: [] }, "field z", warnings)).toBeUndefined(); + expect(warnings).toHaveLength(2); + }); +}); diff --git a/converters/semarchy/tests/roundtrip.test.ts b/converters/semarchy/tests/roundtrip.test.ts new file mode 100644 index 00000000..c5deff8e --- /dev/null +++ b/converters/semarchy/tests/roundtrip.test.ts @@ -0,0 +1,44 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parse as parseYaml } from "yaml"; +import { osiDocumentToSemarchy } from "../src/osi-to-semarchy.js"; +import { semarchyToOsi } from "../src/semarchy-to-osi.js"; +import type { OSIDocument } from "../src/osi-types.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +// TPC-DS baseline model, per the create-tests / converter guides. +const TPCDS = resolve( + __dirname, + "..", + "..", + "..", + "examples", + "tpcds_semantic_model.yaml", +); + +function loadTpcds(): OSIDocument { + return parseYaml(readFileSync(TPCDS, "utf8")) as OSIDocument; +} + +describe("OSI <> Semarchy", () => { + it("exports the TPC-DS baseline without throwing", () => { + const doc = loadTpcds(); + const { model } = osiDocumentToSemarchy(doc); + expect(model.name).toBe(doc.semantic_model[0]!.name); + }); + + // TODO(schema): enable once the Semarchy JSON Schema and the per-construct + // mapping are in place. A full Semarchy -> OSI -> Semarchy roundtrip must be + // information-preserving (custom_extensions for anything without an OSI core + // equivalent), per the converter guide. + it.todo("roundtrips Semarchy -> OSI -> Semarchy without information loss"); + + it("import of an empty Semarchy model yields a valid OSI envelope", () => { + const { document } = semarchyToOsi({ name: "empty" }); + expect(document.version).toBe("0.2.0.dev0"); + expect(document.semantic_model).toHaveLength(1); + expect(document.semantic_model[0]!.name).toBe("empty"); + }); +}); diff --git a/converters/semarchy/tsconfig.json b/converters/semarchy/tsconfig.json new file mode 100644 index 00000000..4dcb82b9 --- /dev/null +++ b/converters/semarchy/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"], + "exclude": ["dist", "node_modules", "tests"] +} diff --git a/converters/semarchy/vitest.config.ts b/converters/semarchy/vitest.config.ts new file mode 100644 index 00000000..ba29bd1c --- /dev/null +++ b/converters/semarchy/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + environment: "node", + }, +}); diff --git a/core-spec/osi-schema.json b/core-spec/osi-schema.json index 72cb164d..8a9bd821 100644 --- a/core-spec/osi-schema.json +++ b/core-spec/osi-schema.json @@ -23,7 +23,7 @@ "$defs": { "Dialect": { "type": "string", - "enum": ["ANSI_SQL", "SNOWFLAKE", "MDX", "TABLEAU", "DATABRICKS", "MAQL"], + "enum": ["ANSI_SQL", "SNOWFLAKE", "MDX", "TABLEAU", "DATABRICKS", "MAQL", "SEMARCHY"], "description": "Supported SQL and expression language dialects" }, "Vendor": { diff --git a/core-spec/spec.md b/core-spec/spec.md index ea7e775d..a2d9ce20 100644 --- a/core-spec/spec.md +++ b/core-spec/spec.md @@ -38,6 +38,7 @@ Supported SQL and expression language dialects for metrics and field definitions | `TABLEAU` | Tableau calculations | | `DATABRICKS` | Databricks SQL | | `MAQL` | GoodData MAQL (Metric Analysis and Query Language) | +| `SEMARCHY` | Semarchy semantic model expression language | ## Semantic Model diff --git a/core-spec/spec.yaml b/core-spec/spec.yaml index ed462032..a329296d 100644 --- a/core-spec/spec.yaml +++ b/core-spec/spec.yaml @@ -19,6 +19,7 @@ dialects: - "TABLEAU" # Tableau - "DATABRICKS" # Databricks SQL - "MAQL" # GoodData MAQL (Multi-Dimensional Analytical Query Language) + - "SEMARCHY" # Semarchy semantic model expression language diff --git a/python/src/osi/models.py b/python/src/osi/models.py index 766b6e0f..bde09b45 100644 --- a/python/src/osi/models.py +++ b/python/src/osi/models.py @@ -14,6 +14,7 @@ class OSIDialect(str, Enum): MAQL = "MAQL" TABLEAU = "TABLEAU" DATABRICKS = "DATABRICKS" + SEMARCHY = "SEMARCHY" class OSIVendor(str, Enum): diff --git a/validation/validate.py b/validation/validate.py index a33cf76d..19dc5d18 100644 --- a/validation/validate.py +++ b/validation/validate.py @@ -41,10 +41,11 @@ "MDX": None, # Not supported by sqlglot, skip validation "TABLEAU": None, # Not supported by sqlglot, skip validation "MAQL": None, # Not supported by sqlglot, skip validation + "SEMARCHY": None, # Not supported by sqlglot, skip validation } # Dialects that sqlglot cannot parse -SKIP_SQL_VALIDATION = {"MDX", "TABLEAU", "MAQL"} +SKIP_SQL_VALIDATION = {"MDX", "TABLEAU", "MAQL", "SEMARCHY"} def validate_schema(data: dict, schema: dict) -> list[str]: From 7a14ca7b5515e83c6c4db918cd7af0175078117f Mon Sep 17 00:00:00 2001 From: Arnaud Mergey Date: Fri, 3 Jul 2026 14:43:39 +0200 Subject: [PATCH 2/7] Implement OSI<>Semarchy core ER mapping Flesh out the scaffolded converter into a working bidirectional mapping between OSI and the Semarchy xDM model, based on the Semarchy model JSON Schemas (Entity, Reference, UniqueKey). Model shape: a Semarchy model is a directory of _type-discriminated YAML objects; the OSI side is a single YAML document. Added io.ts to read/write that directory form. Mapping (per project decision, core ER only): - Entity <-> dataset (source = <_package>.) - EntityAttribute <-> field (name = _name, ANSI_SQL expr = physicalName, Date/Timestamp -> dimension.is_time) - Entity.primaryKey <-> dataset.primary_key - UniqueKey <-> dataset.unique_keys (matched by owning entity) - Reference <-> relationship (fromEntity=many, toEntity=one) Lossy by design (documented in README): metrics, SemQL enrichers, type definitions, views, and UI/process objects are dropped with a warning rather than preserved in custom_extensions. Export fills the Semarchy required fields OSI lacks with sensible defaults; composite keys flatten to their first column. Validation: Ajv2020 for OSI output (draft 2020-12), draft-07 Ajv per Semarchy object type. Bundled schemas under schemas/. Tests (vitest): dialect fallback, TPC-DS export (schema-valid), fixture import (schema-valid, drop-warning, time dimension), and a Semarchy->OSI->Semarchy roundtrip preserving the core ER structure. typecheck, tests (8), and build all pass on Node 20. Co-Authored-By: Claude Opus 4.8 --- converters/semarchy/README.md | 84 ++- converters/semarchy/schemas/Entity.json | 535 ++++++++++++++++++ converters/semarchy/schemas/Reference.json | 245 ++++++++ converters/semarchy/schemas/UniqueKey.json | 96 ++++ converters/semarchy/schemas/osi-schema.json | 330 +++++++++++ converters/semarchy/src/cli.ts | 30 +- converters/semarchy/src/index.ts | 8 +- converters/semarchy/src/io.ts | 98 ++++ converters/semarchy/src/osi-to-semarchy.ts | 204 +++++-- converters/semarchy/src/semarchy-to-osi.ts | 134 ++++- converters/semarchy/src/semarchy-types.ts | 127 ++++- converters/semarchy/src/validation.ts | 79 ++- .../semarchy-model/Entity.Customer.yaml | 28 + .../fixtures/semarchy-model/Entity.Order.yaml | 26 + .../semarchy-model/Form.CustomerForm.yaml | 5 + .../Reference.OrderToCustomer.yaml | 21 + .../semarchy-model/UniqueKey.CustomerUk.yaml | 9 + converters/semarchy/tests/roundtrip.test.ts | 92 ++- 18 files changed, 1987 insertions(+), 164 deletions(-) create mode 100644 converters/semarchy/schemas/Entity.json create mode 100644 converters/semarchy/schemas/Reference.json create mode 100644 converters/semarchy/schemas/UniqueKey.json create mode 100644 converters/semarchy/schemas/osi-schema.json create mode 100644 converters/semarchy/src/io.ts create mode 100644 converters/semarchy/tests/fixtures/semarchy-model/Entity.Customer.yaml create mode 100644 converters/semarchy/tests/fixtures/semarchy-model/Entity.Order.yaml create mode 100644 converters/semarchy/tests/fixtures/semarchy-model/Form.CustomerForm.yaml create mode 100644 converters/semarchy/tests/fixtures/semarchy-model/Reference.OrderToCustomer.yaml create mode 100644 converters/semarchy/tests/fixtures/semarchy-model/UniqueKey.CustomerUk.yaml diff --git a/converters/semarchy/README.md b/converters/semarchy/README.md index 33111652..c34d87a3 100644 --- a/converters/semarchy/README.md +++ b/converters/semarchy/README.md @@ -1,21 +1,21 @@ # osi-semarchy Bidirectional converter between [OSI](../../core-spec/spec.md) and the **Semarchy** -semantic model. +xDM semantic model. - **Export** — OSI → Semarchy (`osi-to-semarchy`) - **Import** — Semarchy → OSI (`semarchy-to-osi`) Unlike the other reference converters (Python / Java), this one is written in -**TypeScript** (Node ≥ 18, ESM). It is offline: it only reads and writes files. +**TypeScript** (Node ≥ 18, ESM). It is fully offline: it only reads and writes +files. -## Status +## Model shape -> ⚠️ **Scaffold — mapping incomplete.** The project structure, CLI, OSI types, -> the dialect-fallback chain, and schema-validation plumbing are in place and -> tested. The per-construct mapping is stubbed pending the **Semarchy semantic -> model JSON Schema** (see [`schemas/README.md`](schemas/README.md)). Every -> incomplete step is marked `TODO(schema)` in the source. +A Semarchy model is a **directory of YAML files**, each file one +`_type`-discriminated object (`Entity`, `Reference`, `UniqueKey`, …). The OSI +side is a single YAML document. So export writes a directory, and import reads +one. ## Install & build @@ -27,39 +27,69 @@ npm test # vitest npm run typecheck ``` +Requires Node ≥ 18 (declared in `engines`). + ## CLI ```bash -# OSI (YAML) -> Semarchy (JSON) -osi-semarchy osi-to-semarchy -i model.yaml -o model.semarchy.json +# OSI (YAML file) -> Semarchy (directory of YAML) +osi-semarchy osi-to-semarchy -i model.yaml -o semarchy-dir/ -# Semarchy (JSON) -> OSI (YAML) -osi-semarchy semarchy-to-osi -i model.semarchy.json -o model.yaml +# Semarchy (directory of YAML) -> OSI (YAML file) +osi-semarchy semarchy-to-osi -i semarchy-dir/ -o model.yaml ``` ## Library ```ts -import { osiDocumentToSemarchy, semarchyToOsi } from "osi-semarchy"; +import { + osiDocumentToSemarchy, + semarchyToOsi, + readSemarchyDir, + writeSemarchyDir, +} from "osi-semarchy"; ``` +## Mapping + +| Semarchy | OSI | Notes | +|----------|-----|-------| +| `Entity` | `dataset` | `source` = `<_package>.` | +| `EntityAttribute` | `field` | field `name` = attribute `_name`; `ANSI_SQL` expression = `physicalName`; `Date`/`Timestamp` → `dimension.is_time` | +| `Entity.primaryKey` | `dataset.primary_key` | | +| `UniqueKey` | `dataset.unique_keys` | matched by owning entity; composite order preserved | +| `Reference` | `relationship` | `fromEntity` = many side → `from`; `toEntity` = one side → `to`; FK column from the foreign attribute / role name | + ## Design notes - **Dialect selection** uses the OSI fallback chain: prefer the `SEMARCHY` - dialect, fall back to `ANSI_SQL`, warn (or error) if neither is present. See - `src/dialect.ts`. `SEMARCHY` was added to the core-spec `Dialect` enum for - this converter. -- **Vendor name** for custom extensions is `SEMARCHY`. Extensions for *other* - vendors are never discarded on import — they roundtrip untouched. -- **Roundtrip fidelity**: anything with no OSI core equivalent is preserved in - `custom_extensions` (`vendor_name: "SEMARCHY"`); `ai_context` a target can't - represent goes into a `COMMON` extension. -- **Validation**: OSI output is checked against `schemas/osi-schema.json` and - Semarchy output against `schemas/semarchy-semantic-model-schema.json` when - those files are present (best-effort; skipped with a warning otherwise). + dialect, fall back to `ANSI_SQL`, warn if neither is present. `SEMARCHY` was + added to the core-spec `Dialect` enum for this converter; it is reserved for a + future SemQL mapping (see limitations). +- **Validation**: OSI output is validated against `schemas/osi-schema.json` + (draft 2020-12); each emitted Semarchy object is validated against its + per-type schema in `schemas/` (draft-07). Best-effort — skipped with a warning + if a schema file is absent. ## Limitations -- Mapping is not yet implemented (see **Status**); requires the Semarchy schema. -- Metrics using dialect-specific SQL absent from both `SEMARCHY` and `ANSI_SQL` - are skipped with a warning rather than emitted. +This converter maps the **core ER model only**. By design (Semarchy is an MDM +platform, not an analytics semantic layer), the roundtrip is **lossy** — the +following are **dropped with a warning**, not preserved in `custom_extensions`: + +- **Metrics** — OSI metrics have no Semarchy equivalent (no measure concept), so + they are dropped on export. +- **SemQL constructs** — `SemQLEnricher` (computed attributes), matchers, and + survivorship rules are not mapped. Consequently, imported field expressions + are plain `ANSI_SQL` column references; the `SEMARCHY` dialect is registered + but not yet emitted. +- **Type definitions** — `ComplexType`, `UserDefinedType`, `LOVType` are dropped. +- **Views** — `DatabaseView`, `BusinessView` are dropped. +- **UI / process objects** — `Form`, `SearchForm`, `Stepper`, `Workflow`, + `DisplayCard`, `CollectionView`, `ActionSet`, `ModelDiagram`, `Application`, + and other MDM-operational objects are dropped. +- **Composite keys**: Semarchy entities use a single primary-key attribute and + references model a single foreign key, so composite OSI primary keys / join + keys are flattened to their first column on export (warned). +- On export, several required Semarchy fields OSI does not carry (physical + names, labels, `entityType`, historization flags) are filled with defaults. diff --git a/converters/semarchy/schemas/Entity.json b/converters/semarchy/schemas/Entity.json new file mode 100644 index 00000000..b9e69e94 --- /dev/null +++ b/converters/semarchy/schemas/Entity.json @@ -0,0 +1,535 @@ +{ + "description": "Logical entity as defined by the ER modeling standards", + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "BuiltInType": { + "enum": [ + "Binary", + "Boolean", + "ByteInteger", + "Date", + "Decimal", + "Integer", + "LongInteger", + "LongText", + "ShortInteger", + "String", + "Timestamp", + "UUID" + ], + "type": "string" + }, + "EntityAttribute": { + "description": "Describes any attribute that ends up stored in a database column.", + "additionalProperties": false, + "properties": { + "_type": { + "const": "EntityAttribute", + "type": "string" + }, + "_name": { + "maxLength": 128, + "pattern": "^[a-zA-Z][a-zA-Z_0-9]*$", + "type": "string" + }, + "label": { + "description": "Label of the object. Used as business-friendly name", + "minLength": 1, + "type": "string" + }, + "description": { + "description": "Description of the object", + "type": "string" + }, + "documentation": { + "description": "The attribute documentation.", + "type": "string" + }, + "dataType": { + "anyOf": [ + { + "$ref": "#/definitions/BuiltInType" + }, + { + "type": "string" + } + ], + "default": "String" + }, + "goldenAttribute": { + "description": "Defines whether attribute makes sense as a golden attribute. Useful to remove useless technical attributes used for matching for example", + "default": true, + "type": "boolean" + }, + "i18ned": { + "description": "Indicates whether this attribute is internationalized (translated).", + "default": false, + "type": "boolean" + }, + "length": { + "description": "Length of atomic attribute", + "default": 128, + "type": "number" + }, + "lovValidationScope": { + "description": "Scope for value in List of Value validation.", + "$ref": "#/definitions/ValidationScopeType" + }, + "mandatory": { + "description": "Indicates whether this attribute is mandatory.", + "default": false, + "type": "boolean" + }, + "mandatoryValidationLabel": { + "description": "Custom error message displayed when a mandatory attribute is left empty.", + "type": "string" + }, + "mandatoryValidationScope": { + "description": "Scope for mandatory value validation.", + "$ref": "#/definitions/ValidationScopeType" + }, + "multiValued": { + "description": "Indicates whether this attribute is a comma-separated list of LOV values", + "default": false, + "type": "boolean" + }, + "physicalName": { + "description": "Physical Name bound to this entity attribute, it is used as a base name when generating the physical database schema", + "pattern": "^[A-Z][A-Z_0-9]*$", + "type": "string" + }, + "precision": { + "description": "Precision of atomic attribute", + "type": "number" + }, + "scale": { + "description": "Scale of atomic attribute", + "type": "number" + }, + "searchable": { + "description": "Indicates whether this attribute participates to search.", + "default": true, + "type": "boolean" + }, + "valueSeparator": { + "description": "Specifies the value separator to use", + "type": "string" + } + }, + "required": [ + "_name", + "_type", + "dataType", + "label", + "physicalName" + ], + "type": "object" + }, + "EntityType": { + "anyOf": [ + { + "description": "Basic entities do not have matching or consolidation capabilities", + "const": "BASIC", + "type": "string" + }, + { + "description": "Records in entities using Fuzzy Matching are matched using a SemQL expression defined in a matcher. \\n(Fuzzy Matching was formerly known as SDPK)", + "const": "FUZZY_MATCHED", + "type": "string" + }, + { + "description": "Records in entities using ID Matching are matched if they have the same IDs. \\nThis matching behavior is well suited when there is a true unique identifier for all the applications communicating with the MDM hub and for simple Data Entry use cases. \\n(ID Matching was formerly known as UDPK)", + "const": "ID_MATCHED", + "type": "string" + } + ] + }, + "GoldenIdGeneration": { + "additionalProperties": false, + "properties": { + "mode": { + "anyOf": [ + { + "description": "IDs are automatically generated using a database sequence", + "const": "SEQUENCE", + "type": "string" + }, + { + "description": "IDs are automatically created using a UUID generator", + "const": "UUID", + "type": "string" + } + ] + }, + "startWith": { + "description": "Starts With Value when using Sequence for GoldenID Generation.", + "maximum": 2147483647, + "minimum": 1, + "type": "number" + } + }, + "required": [ + "mode" + ], + "type": "object" + }, + "IdGenerationType": { + "anyOf": [ + { + "description": "IDs are manually entered by the user", + "const": "MANUAL", + "type": "string" + }, + { + "description": "IDs are automatically generated using a Semql expression", + "const": "SEMQL", + "type": "string" + }, + { + "description": "IDs are automatically generated using a database sequence", + "const": "SEQUENCE", + "type": "string" + }, + { + "description": "IDs are automatically created using a UUID generator", + "const": "UUID", + "type": "string" + } + ] + }, + "SemQLMatcher": { + "description": "SemQL based matcher", + "additionalProperties": false, + "properties": { + "_type": { + "const": "SemQLMatcher", + "type": "string" + }, + "_name": { + "maxLength": 128, + "pattern": "^[a-zA-Z][a-zA-Z_0-9]*$", + "type": "string" + }, + "description": { + "description": "Description of the object", + "type": "string" + }, + "autoConfirmGoldenThreshold": { + "description": "With a confidence score strictly greater than this value, the matcher will confirm non-singleton golden records.", + "maximum": 100, + "minimum": 0, + "type": "number" + }, + "autoConfirmSingletons": { + "description": "Select this option to automatically confirm golden records composed of a single master record", + "default": false, + "type": "boolean" + }, + "matchRules": { + "items": { + "$ref": "#/definitions/SemQLMatchRule" + }, + "minItems": 1, + "type": "array" + }, + "mergeThresholdMergingConfirmed": { + "description": "With a confidence score strictly greater than this value, the matcher will merge confirmed golden records.", + "maximum": 100, + "minimum": 0, + "type": "number" + }, + "mergeThresholdMergingConfirmedWithNewMasters": { + "description": "With a confidence score strictly greater than this value, the matcher will add new master records to a confirmed golden record.", + "maximum": 100, + "minimum": 0, + "type": "number" + }, + "mergeThresholdMergingConfirmedWithUnconfirmed": { + "description": "With a confidence score strictly greater than this value, the matcher will merge unconfirmed with confirmed golden records.", + "maximum": 100, + "minimum": 0, + "type": "number" + }, + "mergeThresholdMergingUnconfirmed": { + "description": "With a confidence score strictly greater than this value, the matcher will merge unconfirmed golden records.", + "maximum": 100, + "minimum": 0, + "type": "number" + }, + "mergeThresholdMergingUnconfirmedWithNewMasters": { + "description": "With a confidence score strictly greater than this value, the matcher will add new master records to an unconfirmed golden record.", + "maximum": 100, + "minimum": 0, + "type": "number" + }, + "mergeThresholdNewGroup": { + "description": "With a confidence score strictly greater than this value, the matcher will create a golden record from new master records.", + "maximum": 100, + "minimum": 0, + "type": "number" + }, + "mergeThresholdRemergingPreviouslySplit": { + "description": "With a confidence score strictly greater than this value, the matcher will merge confirmed golden records previously split by the user.", + "maximum": 100, + "minimum": 0, + "type": "number" + }, + "useMultiIterationGrouping": { + "description": "Grouping will run one Iteration per Match Score", + "default": true, + "type": "boolean" + }, + "useTransitiveMatchScore": { + "description": "Use Transitive Match Score while computing Confidence Score", + "default": true, + "type": "boolean" + } + }, + "required": [ + "_name", + "_type", + "autoConfirmGoldenThreshold", + "autoConfirmSingletons", + "matchRules", + "mergeThresholdMergingConfirmed", + "mergeThresholdMergingConfirmedWithNewMasters", + "mergeThresholdMergingConfirmedWithUnconfirmed", + "mergeThresholdMergingUnconfirmed", + "mergeThresholdMergingUnconfirmedWithNewMasters", + "mergeThresholdNewGroup", + "mergeThresholdRemergingPreviouslySplit", + "useMultiIterationGrouping", + "useTransitiveMatchScore" + ], + "type": "object" + }, + "SemQLMatchRuleBinningExpression": { + "description": "Defines a matchrule binning", + "additionalProperties": false, + "properties": { + "expression": { + "description": "Binning expression used to reduce the data set into reasonable buckets before execution of the match process", + "type": "string" + } + }, + "required": [ + "expression" + ], + "type": "object" + }, + "SemQLMatchRule": { + "description": "Defines of a match rule", + "additionalProperties": false, + "properties": { + "_type": { + "const": "SemQLMatchRule", + "type": "string" + }, + "_name": { + "maxLength": 128, + "pattern": "^[a-zA-Z][a-zA-Z_0-9]*$", + "type": "string" + }, + "label": { + "description": "Label of the object. Used as business-friendly name", + "minLength": 1, + "type": "string" + }, + "description": { + "description": "Description of the object", + "type": "string" + }, + "documentation": { + "description": "Documentation displayed in the documentation panel. Markdown syntax is supported.", + "type": "string" + }, + "binningExpressions": { + "items": { + "$ref": "#/definitions/SemQLMatchRuleBinningExpression" + }, + "type": "array" + }, + "color": { + "description": "Color used in the explain record and duplicates management graph edges representing matches between two records", + "type": "string" + }, + "condition": { + "description": "SemQL Condition for matching. When true, the two records are considered as matching with the match score.", + "type": "string" + }, + "matchOnExpression": { + "description": "Expression returning the child records used for matching.", + "type": "string" + }, + "matchScore": { + "description": "Match Score for two records matching according to this match rule.", + "type": "number" + }, + "usingMatchOn": { + "description": "Match using child records of the current entity records.", + "default": false, + "type": "boolean" + } + }, + "required": [ + "_name", + "_type", + "condition", + "label", + "matchScore" + ], + "type": "object" + }, + "SourceIdGeneration": { + "additionalProperties": false, + "properties": { + "idExpression": { + "description": "SemqlExpression used to compute the id when Semql Id Generation is used", + "type": "string" + }, + "mode": { + "$ref": "#/definitions/IdGenerationType" + }, + "startWith": { + "description": "Starts With Value when using Sequence for SourceID Generation.", + "maximum": 2147483647, + "minimum": 1, + "type": "number" + } + }, + "required": [ + "mode" + ], + "type": "object" + }, + "ValidationScopeType": { + "anyOf": [ + { + "description": "Never validate data", + "const": "NONE", + "type": "string" + }, + { + "description": "Validates data after consolidation", + "const": "POST_CONSO", + "type": "string" + }, + { + "description": "Validates source data before consolidation", + "const": "PRE_CONSO", + "type": "string" + }, + { + "description": "Validates data both before and after consolidation", + "const": "PRE_POST", + "type": "string" + } + ] + } + }, + "properties": { + "_package": { + "type": "string" + }, + "_type": { + "const": "Entity", + "type": "string" + }, + "_name": { + "maxLength": 128, + "pattern": "^[a-zA-Z][a-zA-Z_0-9]*$", + "type": "string" + }, + "label": { + "description": "Label of the object. Used as business-friendly name", + "minLength": 1, + "type": "string" + }, + "description": { + "description": "Description of the object", + "type": "string" + }, + "documentation": { + "description": "Entity documentation displayed in the documentation panel. Markdown syntax is supported.", + "type": "string" + }, + "attributes": { + "items": { + "$ref": "#/definitions/EntityAttribute" + }, + "minItems": 1, + "type": "array" + }, + "dataEntryAllowed": { + "description": "Defines whether entity supports creating and editing \"Non Consolidated Golden\" records authored directly in MDM", + "default": true, + "type": "boolean" + }, + "enableDelete": { + "description": "Indicates whether delete is enabled for this entity", + "default": true, + "type": "boolean" + }, + "entityType": { + "description": "Defines the type of entity and the technique used for matching records in this entity.", + "$ref": "#/definitions/EntityType", + "default": "BASIC" + }, + "goldenIdGeneration": { + "description": "Defines how golden record primary keys are generated for fuzzy matched entities.", + "$ref": "#/definitions/GoldenIdGeneration" + }, + "historizeGolden": { + "description": "Select this option to automatically historize changes made on the golden records for this entity. This history is retained according to the entity''s retention policy.", + "default": false, + "type": "boolean" + }, + "historizeMaster": { + "description": "Select this option to automatically historize changes made on the master records for this entity. This history is retained according to the entity''s retention policy.", + "default": false, + "type": "boolean" + }, + "iconUrl": { + "description": "Default Icon representing the records of the entity", + "type": "string" + }, + "matcher": { + "$ref": "#/definitions/SemQLMatcher" + }, + "parentEntity": { + "type": "string" + }, + "physicalTableName": { + "description": "Physical Table Name used as a unique name to generate tables for base entities", + "pattern": "^[A-Z][A-Z_0-9]*$", + "type": "string" + }, + "pluralLabel": { + "description": "Plural label of the object. Used to distinguish between singular and plural instances of this object", + "minLength": 1, + "type": "string" + }, + "primaryKey": { + "description": "An optional reference to an attribute that is the primary key, required unless the entity extends from another one.", + "type": "string" + }, + "sourceIdGeneration": { + "description": "Defines how records ID are generated in Data Entry workflows and integration APIs.", + "$ref": "#/definitions/SourceIdGeneration" + } + }, + "required": [ + "_name", + "_package", + "_type", + "attributes", + "entityType", + "historizeGolden", + "historizeMaster", + "label", + "physicalTableName", + "pluralLabel" + ], + "type": "object" +} \ No newline at end of file diff --git a/converters/semarchy/schemas/Reference.json b/converters/semarchy/schemas/Reference.json new file mode 100644 index 00000000..9c188ad0 --- /dev/null +++ b/converters/semarchy/schemas/Reference.json @@ -0,0 +1,245 @@ +{ + "description": "A reference is a relationship from an entity to another entity. It translates into a single ER foreign key.", + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "DeletePropagationType": { + "anyOf": [ + { + "description": "Delete referencing records when referenced record is deleted.", + "const": "CASCADE", + "type": "string" + }, + { + "description": "Set reference to null on the referencing record when referenced record is deleted.", + "const": "NULLIFY", + "type": "string" + }, + { + "description": "Prevent referenced record deletion if a referencing record exists.", + "const": "RESTRICT", + "type": "string" + } + ] + }, + "ForeignAttribute": { + "description": "A foreign attribute is the attribute that is derived from a reference relationship. Foreign attributes show up as non modifiable attributes in entities inherited from the relationships where they are referred to.", + "additionalProperties": false, + "properties": { + "_type": { + "const": "ForeignAttribute", + "type": "string" + }, + "entity": { + "type": "string" + }, + "_name": { + "maxLength": 128, + "pattern": "^[a-zA-Z][a-zA-Z_0-9]*$", + "type": "string" + }, + "label": { + "description": "Label of the object. Used as business-friendly name", + "minLength": 1, + "type": "string" + }, + "description": { + "description": "Description of the object", + "type": "string" + }, + "documentation": { + "description": "The attribute documentation.", + "type": "string" + }, + "physicalName": { + "description": "Physical Name of the foreign attribute used when generating the physical database schema", + "pattern": "^[A-Z][A-Z_0-9]*$", + "type": "string" + }, + "searchable": { + "description": "Indicates whether this attribute participates to search.", + "default": true, + "type": "boolean" + } + }, + "required": [ + "_name", + "_type", + "entity", + "label", + "physicalName" + ], + "type": "object" + }, + "SplitDuplicatePropagationType": { + "anyOf": [ + { + "description": "None", + "const": "NONE", + "type": "string" + }, + { + "description": "Reset Matching", + "const": "RESET_MATCHING", + "type": "string" + } + ] + }, + "ValidationScopeType": { + "anyOf": [ + { + "description": "Never validate data", + "const": "NONE", + "type": "string" + }, + { + "description": "Validates data after consolidation", + "const": "POST_CONSO", + "type": "string" + }, + { + "description": "Validates source data before consolidation", + "const": "PRE_CONSO", + "type": "string" + }, + { + "description": "Validates data both before and after consolidation", + "const": "PRE_POST", + "type": "string" + } + ] + } + }, + "properties": { + "_package": { + "type": "string" + }, + "_type": { + "const": "Reference", + "type": "string" + }, + "_name": { + "maxLength": 128, + "pattern": "^[a-zA-Z][a-zA-Z_0-9]*$", + "type": "string" + }, + "label": { + "description": "Label of the object. Used as business-friendly name", + "minLength": 1, + "type": "string" + }, + "description": { + "description": "Description of the object", + "type": "string" + }, + "documentation": { + "description": "Documentation displayed in the documentation panel. Markdown syntax is supported.", + "type": "string" + }, + "brokenValidationLabel": { + "description": "Custom error message displayed for a broken reference", + "type": "string" + }, + "deletePropagation": { + "description": "Defines how delete propagates through this reference", + "$ref": "#/definitions/DeletePropagationType" + }, + "foreignAttribute": { + "$ref": "#/definitions/ForeignAttribute" + }, + "fromEntity": { + "type": "string" + }, + "fromNavigable": { + "description": "Indicates whether this participant association end is navigable from the one side to the many side. By default, it is.", + "default": true, + "type": "boolean" + }, + "fromRoleDocumentation": { + "description": "Documentation of the many side displayed in the documentation panel. Markdown syntax is supported.", + "type": "string" + }, + "fromRoleLabel": { + "description": "Label of the many side", + "minLength": 1, + "type": "string" + }, + "fromRoleName": { + "description": "Name of the many side", + "type": "string" + }, + "fromRolePluralLabel": { + "description": "Plural label of the many side", + "minLength": 1, + "type": "string" + }, + "mandatoryValidationLabel": { + "description": "Custom error message displayed when a mandatory reference is empty.", + "type": "string" + }, + "oneToMany": { + "description": "Indicates whether current reference is a mandatory Many to 1 or an optional Many to 0.", + "default": false, + "type": "boolean" + }, + "physicalName": { + "description": "Physical Name for the reference (used to create indexes)", + "pattern": "^[A-Z][A-Z_0-9]*$", + "type": "string" + }, + "splitDuplicatePropagation": { + "description": "Defines how a parent record split propagates to child records.", + "$ref": "#/definitions/SplitDuplicatePropagationType" + }, + "toEntity": { + "type": "string" + }, + "toNavigable": { + "description": "Indicates whether this participant association end is navigable from the many side to the one side. By default, it is.", + "default": true, + "type": "boolean" + }, + "toRoleDocumentation": { + "description": "Documentation of the one side displayed in the documentation panel. Markdown syntax is supported.", + "type": "string" + }, + "toRoleLabel": { + "description": "Label of the one side", + "minLength": 1, + "type": "string" + }, + "toRoleName": { + "description": "Name of the one side. Used to set the name of the foreign attribute.", + "maxLength": 128, + "pattern": "^[a-zA-Z][a-zA-Z_0-9]*$", + "type": "string" + }, + "toRolePhysicalName": { + "description": "A physical name for the referenced column foreign key", + "pattern": "^[A-Z][A-Z_0-9]*$", + "type": "string" + }, + "validationScope": { + "description": "Scope for reference validation.", + "$ref": "#/definitions/ValidationScopeType" + } + }, + "required": [ + "_name", + "_package", + "_type", + "deletePropagation", + "fromEntity", + "fromRoleLabel", + "fromRoleName", + "fromRolePluralLabel", + "label", + "physicalName", + "toEntity", + "toRoleLabel", + "toRoleName", + "toRolePhysicalName", + "validationScope" + ], + "type": "object" +} \ No newline at end of file diff --git a/converters/semarchy/schemas/UniqueKey.json b/converters/semarchy/schemas/UniqueKey.json new file mode 100644 index 00000000..2782737b --- /dev/null +++ b/converters/semarchy/schemas/UniqueKey.json @@ -0,0 +1,96 @@ +{ + "description": "A logical unique key as defined for an entity", + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "KeyAttribute": { + "description": "A reference to an atomic attribute that is part of the owner key", + "additionalProperties": false, + "properties": { + "attribute": { + "type": "string" + } + }, + "required": [ + "attribute" + ], + "type": "object" + }, + "ValidationScopeType": { + "anyOf": [ + { + "description": "Never validate data", + "const": "NONE", + "type": "string" + }, + { + "description": "Validates data after consolidation", + "const": "POST_CONSO", + "type": "string" + }, + { + "description": "Validates source data before consolidation", + "const": "PRE_CONSO", + "type": "string" + }, + { + "description": "Validates data both before and after consolidation", + "const": "PRE_POST", + "type": "string" + } + ] + } + }, + "properties": { + "_package": { + "type": "string" + }, + "_type": { + "const": "UniqueKey", + "type": "string" + }, + "entity": { + "type": "string" + }, + "_name": { + "maxLength": 128, + "pattern": "^[a-zA-Z][a-zA-Z_0-9]*$", + "type": "string" + }, + "label": { + "description": "Label of the object. Used as business-friendly name", + "minLength": 1, + "type": "string" + }, + "description": { + "description": "Description of the object", + "type": "string" + }, + "keyAttributes": { + "items": { + "$ref": "#/definitions/KeyAttribute" + }, + "minItems": 1, + "type": "array" + }, + "validationLabel": { + "description": "Custom error message displayed when this unique key validation is not met.", + "type": "string" + }, + "validationScope": { + "description": "Scope for unique key validation.", + "$ref": "#/definitions/ValidationScopeType" + } + }, + "required": [ + "_name", + "_package", + "_type", + "entity", + "keyAttributes", + "label", + "validationLabel", + "validationScope" + ], + "type": "object" +} \ No newline at end of file diff --git a/converters/semarchy/schemas/osi-schema.json b/converters/semarchy/schemas/osi-schema.json new file mode 100644 index 00000000..8a9bd821 --- /dev/null +++ b/converters/semarchy/schemas/osi-schema.json @@ -0,0 +1,330 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/open-semantic-interchange/OSI/core-spec/osi-schema.json", + "title": "OSI Core Metadata Specification", + "description": "JSON Schema for validating OSI (Open Semantic Interoperability) semantic model definitions", + "type": "object", + "properties": { + "version": { + "type": "string", + "const": "0.2.0.dev0", + "description": "OSI specification version" + }, + "semantic_model": { + "type": "array", + "description": "Collection of semantic model definitions", + "items": { + "$ref": "#/$defs/SemanticModel" + } + } + }, + "required": ["version", "semantic_model"], + "additionalProperties": false, + "$defs": { + "Dialect": { + "type": "string", + "enum": ["ANSI_SQL", "SNOWFLAKE", "MDX", "TABLEAU", "DATABRICKS", "MAQL", "SEMARCHY"], + "description": "Supported SQL and expression language dialects" + }, + "Vendor": { + "type": "string", + "examples": ["COMMON", "SNOWFLAKE", "SALESFORCE", "DBT", "DATABRICKS", "GOODDATA"], + "description": "Vendor name for custom extensions. Any string value is accepted." + }, + "AIContext": { + "description": "Additional context for AI tools", + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "instructions": { + "type": "string", + "description": "Instructions for AI on how to use this entity" + }, + "synonyms": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Alternative names and terms" + }, + "examples": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Sample questions or use cases" + } + }, + "additionalProperties": true + } + ] + }, + "CustomExtension": { + "type": "object", + "description": "Vendor-specific attributes for extensibility", + "properties": { + "vendor_name": { + "$ref": "#/$defs/Vendor" + }, + "data": { + "type": "string", + "description": "JSON string containing vendor-specific data" + } + }, + "required": ["vendor_name", "data"], + "additionalProperties": false + }, + "DialectExpression": { + "type": "object", + "description": "Expression in a specific dialect", + "properties": { + "dialect": { + "$ref": "#/$defs/Dialect" + }, + "expression": { + "type": "string", + "description": "SQL or dialect-specific expression" + } + }, + "required": ["dialect", "expression"], + "additionalProperties": false + }, + "Expression": { + "type": "object", + "description": "Expression definition with multi-dialect support", + "properties": { + "dialects": { + "type": "array", + "items": { + "$ref": "#/$defs/DialectExpression" + }, + "minItems": 1 + } + }, + "required": ["dialects"], + "additionalProperties": false + }, + "Dimension": { + "type": "object", + "description": "Dimension metadata", + "properties": { + "is_time": { + "type": "boolean", + "description": "Indicates if this is a time-based dimension for temporal filtering" + } + }, + "additionalProperties": false + }, + "Field": { + "type": "object", + "description": "Row-level attribute for grouping, filtering, and metric expressions", + "properties": { + "name": { + "type": "string", + "description": "Unique identifier for the field within the dataset" + }, + "expression": { + "$ref": "#/$defs/Expression" + }, + "dimension": { + "$ref": "#/$defs/Dimension" + }, + "label": { + "type": "string", + "description": "Label for categorization" + }, + "description": { + "type": "string", + "description": "Human-readable description" + }, + "ai_context": { + "$ref": "#/$defs/AIContext" + }, + "custom_extensions": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomExtension" + } + } + }, + "required": ["name", "expression"], + "additionalProperties": false + }, + "Dataset": { + "type": "object", + "description": "Logical dataset representing a business entity (fact or dimension table)", + "properties": { + "name": { + "type": "string", + "description": "Unique identifier for the dataset" + }, + "source": { + "type": "string", + "description": "Reference to underlying physical table/view (database.schema.table) or query" + }, + "primary_key": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Primary key columns (single or composite)" + }, + "unique_keys": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Array of unique key definitions (each can be single or composite)" + }, + "description": { + "type": "string", + "description": "Human-readable description" + }, + "ai_context": { + "$ref": "#/$defs/AIContext" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/$defs/Field" + } + }, + "custom_extensions": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomExtension" + } + } + }, + "required": ["name", "source"], + "additionalProperties": false + }, + "Relationship": { + "type": "object", + "description": "Foreign key relationship between datasets", + "properties": { + "name": { + "type": "string", + "description": "Unique identifier for the relationship" + }, + "from": { + "type": "string", + "description": "Dataset on the many side of the relationship" + }, + "to": { + "type": "string", + "description": "Dataset on the one side of the relationship" + }, + "from_columns": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "Foreign key columns in the 'from' dataset" + }, + "to_columns": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "Primary/unique key columns in the 'to' dataset" + }, + "ai_context": { + "$ref": "#/$defs/AIContext" + }, + "custom_extensions": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomExtension" + } + } + }, + "required": ["name", "from", "to", "from_columns", "to_columns"], + "additionalProperties": false + }, + "Metric": { + "type": "object", + "description": "Quantitative measure defined on business data", + "properties": { + "name": { + "type": "string", + "description": "Unique identifier for the metric" + }, + "expression": { + "$ref": "#/$defs/Expression" + }, + "description": { + "type": "string", + "description": "Human-readable description of what the metric measures" + }, + "ai_context": { + "$ref": "#/$defs/AIContext" + }, + "custom_extensions": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomExtension" + } + } + }, + "required": ["name", "expression"], + "additionalProperties": false + }, + "SemanticModel": { + "type": "object", + "description": "Top-level container representing a complete semantic model", + "properties": { + "name": { + "type": "string", + "description": "Unique identifier for the semantic model" + }, + "description": { + "type": "string", + "description": "Human-readable description" + }, + "ai_context": { + "$ref": "#/$defs/AIContext" + }, + "datasets": { + "type": "array", + "items": { + "$ref": "#/$defs/Dataset" + }, + "minItems": 1, + "description": "Collection of logical datasets" + }, + "relationships": { + "type": "array", + "items": { + "$ref": "#/$defs/Relationship" + }, + "description": "Defines how datasets are connected" + }, + "metrics": { + "type": "array", + "items": { + "$ref": "#/$defs/Metric" + }, + "description": "Quantifiable measures spanning datasets" + }, + "custom_extensions": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomExtension" + } + } + }, + "required": ["name", "datasets"], + "additionalProperties": false + } + } +} diff --git a/converters/semarchy/src/cli.ts b/converters/semarchy/src/cli.ts index 50b5b84e..4de6abbd 100644 --- a/converters/semarchy/src/cli.ts +++ b/converters/semarchy/src/cli.ts @@ -2,23 +2,26 @@ /** * osi-semarchy CLI. * - * osi-semarchy osi-to-semarchy -i model.yaml -o model.semarchy.json - * osi-semarchy semarchy-to-osi -i model.semarchy.json -o model.yaml + * osi-semarchy osi-to-semarchy -i model.yaml -o semarchy-dir/ + * osi-semarchy semarchy-to-osi -i semarchy-dir/ -o model.yaml + * + * The Semarchy side is a directory of `_type`-discriminated YAML objects; the + * OSI side is a single YAML document. */ import { readFileSync, writeFileSync } from "node:fs"; import { Command } from "commander"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { osiDocumentToSemarchy } from "./osi-to-semarchy.js"; import { semarchyToOsi } from "./semarchy-to-osi.js"; -import { validateOsi, validateSemarchy } from "./validation.js"; +import { readSemarchyDir, writeSemarchyDir } from "./io.js"; +import { validateOsi, validateSemarchy, type ValidationOutcome } from "./validation.js"; import type { OSIDocument } from "./osi-types.js"; -import type { SemarchyModel } from "./semarchy-types.js"; function emitWarnings(warnings: string[]): void { for (const w of warnings) process.stderr.write(`warning: ${w}\n`); } -function reportValidation(label: string, outcome: ReturnType): void { +function reportValidation(label: string, outcome: ValidationOutcome): void { if (outcome.skipped) { process.stderr.write(`warning: ${label} ${outcome.errors[0]}\n`); } else if (!outcome.valid) { @@ -35,26 +38,27 @@ program program .command("osi-to-semarchy") - .description("Convert an OSI model (YAML) to a Semarchy model (JSON)") + .description("Convert an OSI model (YAML file) to a Semarchy model (directory of YAML)") .requiredOption("-i, --input ", "input OSI YAML file") - .requiredOption("-o, --output ", "output Semarchy JSON file") + .requiredOption("-o, --output ", "output Semarchy directory") .action((opts: { input: string; output: string }) => { const doc = parseYaml(readFileSync(opts.input, "utf8")) as OSIDocument; const { model, warnings } = osiDocumentToSemarchy(doc); emitWarnings(warnings); reportValidation("semarchy output", validateSemarchy(model)); - writeFileSync(opts.output, JSON.stringify(model, null, 2) + "\n"); + writeSemarchyDir(opts.output, model); }); program .command("semarchy-to-osi") - .description("Convert a Semarchy model (JSON) to an OSI model (YAML)") - .requiredOption("-i, --input ", "input Semarchy JSON file") + .description("Convert a Semarchy model (directory of YAML) to an OSI model (YAML file)") + .requiredOption("-i, --input ", "input Semarchy directory") .requiredOption("-o, --output ", "output OSI YAML file") .action((opts: { input: string; output: string }) => { - const model = JSON.parse(readFileSync(opts.input, "utf8")) as SemarchyModel; - const { document, warnings } = semarchyToOsi(model); - emitWarnings(warnings); + const warnings: string[] = []; + const model = readSemarchyDir(opts.input, warnings); + const { document, warnings: mapWarnings } = semarchyToOsi(model, warnings); + emitWarnings(mapWarnings); reportValidation("osi output", validateOsi(document)); writeFileSync(opts.output, stringifyYaml(document)); }); diff --git a/converters/semarchy/src/index.ts b/converters/semarchy/src/index.ts index 1ad8b842..8167fba5 100644 --- a/converters/semarchy/src/index.ts +++ b/converters/semarchy/src/index.ts @@ -6,10 +6,6 @@ export type { ExportResult } from "./osi-to-semarchy.js"; export { semarchyToOsi } from "./semarchy-to-osi.js"; export type { ImportResult } from "./semarchy-to-osi.js"; export { selectExpression } from "./dialect.js"; -export { - validateSemarchy, - validateOsi, - SEMARCHY_SCHEMA_PATH, - OSI_SCHEMA_PATH, -} from "./validation.js"; +export { readSemarchyDir, writeSemarchyDir } from "./io.js"; +export { validateSemarchy, validateOsi, OSI_SCHEMA_PATH } from "./validation.js"; export type { ValidationOutcome } from "./validation.js"; diff --git a/converters/semarchy/src/io.ts b/converters/semarchy/src/io.ts new file mode 100644 index 00000000..4c73da13 --- /dev/null +++ b/converters/semarchy/src/io.ts @@ -0,0 +1,98 @@ +/** + * Directory-of-YAML I/O for Semarchy models. + * + * A Semarchy model is delivered as a directory of YAML files; each YAML + * document is one `_type`-discriminated object. On read we group the mapped + * object types into a `SemarchyModel` and warn about (drop) everything else. + * On write we emit one YAML file per object, named `<_type>.<_name>.yaml`. + */ +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { parseAllDocuments, stringify as stringifyYaml } from "yaml"; +import type { + SemarchyEntity, + SemarchyModel, + SemarchyObject, + SemarchyReference, + SemarchyUniqueKey, +} from "./semarchy-types.js"; + +/** Semarchy object types this converter maps; all others are dropped on read. */ +const MAPPED_TYPES = new Set(["Entity", "Reference", "UniqueKey"]); + +function listYamlFiles(dir: string): string[] { + if (!existsSync(dir) || !statSync(dir).isDirectory()) { + throw new Error(`not a directory: ${dir}`); + } + return readdirSync(dir) + .filter((f) => /\.ya?ml$/i.test(f)) + .sort() + .map((f) => join(dir, f)); +} + +/** Read a directory of Semarchy YAML objects into a grouped model. */ +export function readSemarchyDir( + dir: string, + warnings: string[] = [], +): SemarchyModel { + const entities: SemarchyEntity[] = []; + const references: SemarchyReference[] = []; + const uniqueKeys: SemarchyUniqueKey[] = []; + const packages = new Set(); + + for (const file of listYamlFiles(dir)) { + const docs = parseAllDocuments(readFileSync(file, "utf8")); + for (const doc of docs) { + const obj = doc.toJSON() as SemarchyObject | null; + if (!obj || typeof obj !== "object" || !("_type" in obj)) continue; + const type = obj._type; + if (typeof obj._package === "string") packages.add(obj._package); + if (!MAPPED_TYPES.has(type)) { + warnings.push( + `[drop] unsupported Semarchy type "${type}"` + + (obj._name ? ` (${obj._name})` : "") + + ` in ${file}`, + ); + continue; + } + if (type === "Entity") entities.push(obj as SemarchyEntity); + else if (type === "Reference") references.push(obj as SemarchyReference); + else if (type === "UniqueKey") uniqueKeys.push(obj as SemarchyUniqueKey); + } + } + + if (packages.size > 1) { + warnings.push( + `[model] multiple packages found (${[...packages].join(", ")}); ` + + `using "${[...packages][0]}" as the model name`, + ); + } + + return { + pkg: [...packages][0] ?? "model", + entities, + references, + uniqueKeys, + }; +} + +/** Write a Semarchy model to a directory, one YAML file per object. */ +export function writeSemarchyDir(dir: string, model: SemarchyModel): void { + mkdirSync(dir, { recursive: true }); + const objects: SemarchyObject[] = [ + ...model.entities, + ...model.uniqueKeys, + ...model.references, + ]; + for (const obj of objects) { + const name = `${obj._type}.${obj._name ?? "unnamed"}.yaml`; + writeFileSync(join(dir, name), stringifyYaml(obj)); + } +} diff --git a/converters/semarchy/src/osi-to-semarchy.ts b/converters/semarchy/src/osi-to-semarchy.ts index 02c1c0d8..a2e5d101 100644 --- a/converters/semarchy/src/osi-to-semarchy.ts +++ b/converters/semarchy/src/osi-to-semarchy.ts @@ -1,67 +1,189 @@ /** * Export: OSI -> Semarchy. * - * Follows the 9-step export procedure from the converter guide. The dataset / - * field / relationship / metric traversal and the dialect-fallback plumbing are - * in place; the per-construct emission into Semarchy shapes is marked TODO until - * the Semarchy JSON Schema lands. + * Scope (per project decision): dataset -> Entity, field -> EntityAttribute, + * unique_keys -> UniqueKey, relationship -> Reference. OSI metrics have no + * Semarchy target and are dropped with a warning. Semarchy requires several + * fields OSI does not carry (physical names, labels, entity type, etc.); those + * are filled with sensible defaults so the output validates against the + * Semarchy schemas. */ import { selectExpression } from "./dialect.js"; -import type { OSIDocument, OSISemanticModel } from "./osi-types.js"; -import type { SemarchyModel } from "./semarchy-types.js"; +import type { + OSIDataset, + OSIDocument, + OSIField, + OSIRelationship, + OSISemanticModel, +} from "./osi-types.js"; +import type { + SemarchyEntity, + SemarchyEntityAttribute, + SemarchyModel, + SemarchyReference, + SemarchyUniqueKey, +} from "./semarchy-types.js"; export interface ExportResult { model: SemarchyModel; warnings: string[]; } -const SEMARCHY_VENDOR = "SEMARCHY"; +/** Semarchy `_name` pattern: ^[a-zA-Z][a-zA-Z_0-9]*$. */ +function logicalName(name: string, warnings: string[], what: string): string { + const cleaned = name.replace(/[^a-zA-Z0-9_]/g, "_").replace(/^([^a-zA-Z])/, "n$1"); + if (cleaned !== name) { + warnings.push(`[rename] ${what} "${name}" -> "${cleaned}" (Semarchy name rules)`); + } + return cleaned; +} -/** Convert a single OSI semantic model to a Semarchy model. */ -export function osiToSemarchy( - osi: OSISemanticModel, - warnings: string[] = [], -): ExportResult { - const model: SemarchyModel = { - name: osi.name, - description: osi.description, +/** Semarchy physicalName pattern: ^[A-Z][A-Z_0-9]*$. */ +function physicalName(name: string): string { + const upper = name.toUpperCase().replace(/[^A-Z0-9_]/g, "_"); + return /^[A-Z]/.test(upper) ? upper : `T_${upper}`; +} + +/** Best-effort Semarchy data type from an OSI field. */ +function dataTypeOf(field: OSIField): string { + return field.dimension?.is_time ? "Timestamp" : "String"; +} + +function fieldToAttribute( + field: OSIField, + warnings: string[], +): SemarchyEntityAttribute { + // The physical column comes from the selected expression when it is a bare + // column reference; otherwise fall back to the field name. + const sel = selectExpression(field.expression, `field ${field.name}`, warnings); + const column = sel?.expression ?? field.name; + return { + _type: "EntityAttribute", + _name: logicalName(field.name, warnings, "attribute"), + label: field.label ?? field.name, + physicalName: physicalName(column), + dataType: dataTypeOf(field), + ...(field.description ? { description: field.description } : {}), + }; +} + +function datasetToEntity( + dataset: OSIDataset, + pkg: string, + warnings: string[], +): SemarchyEntity { + const fields = dataset.fields ?? []; + if (fields.length === 0) { + warnings.push( + `[warn] dataset "${dataset.name}": no fields; Semarchy entities require ` + + `at least one attribute`, + ); + } + // source is `db.schema.table`; the physical table name is the last segment. + const table = dataset.source.split(".").pop() ?? dataset.name; + + const entity: SemarchyEntity = { + _type: "Entity", + _package: pkg, + _name: logicalName(dataset.name, warnings, "entity"), + label: dataset.name, + pluralLabel: `${dataset.name}s`, + physicalTableName: physicalName(table), + entityType: "BASIC", + historizeGolden: false, + historizeMaster: false, + attributes: fields.map((f) => fieldToAttribute(f, warnings)), }; + if (dataset.description) entity.description = dataset.description; - // Step 3-4: datasets and their fields (dialect fallback: SEMARCHY -> ANSI_SQL). - for (const dataset of osi.datasets ?? []) { - for (const field of dataset.fields ?? []) { - selectExpression( - field.expression, - `dataset ${dataset.name}, field ${field.name}`, - warnings, + if (dataset.primary_key?.length) { + if (dataset.primary_key.length > 1) { + warnings.push( + `[warn] dataset "${dataset.name}": composite primary key ` + + `[${dataset.primary_key.join(", ")}] flattened to first column ` + + `(Semarchy uses a single primary-key attribute)`, ); - // TODO(schema): emit Semarchy attribute from the selected expression. } - // TODO(schema): emit Semarchy entity (source, primary_key, unique_keys). + entity.primaryKey = logicalName(dataset.primary_key[0]!, warnings, "attribute"); } - // Step 5: relationships (preserve composite-key column ordering). - for (const _rel of osi.relationships ?? []) { - // TODO(schema): emit Semarchy relationship (from/to, from_columns/to_columns). - } + return entity; +} - // Step 6: metrics (same dialect logic; resolve cross-dataset references). - for (const metric of osi.metrics ?? []) { - selectExpression(metric.expression, `metric ${metric.name}`, warnings); - // TODO(schema): emit Semarchy measure. - } +function uniqueKeysOf( + dataset: OSIDataset, + pkg: string, + warnings: string[], +): SemarchyUniqueKey[] { + return (dataset.unique_keys ?? []).map((cols, i) => ({ + _type: "UniqueKey", + _package: pkg, + _name: logicalName(`${dataset.name}_uk${i + 1}`, warnings, "unique key"), + label: `${dataset.name} unique key ${i + 1}`, + entity: logicalName(dataset.name, warnings, "entity"), + keyAttributes: cols.map((c) => ({ + attribute: logicalName(c, warnings, "attribute"), + })), + validationLabel: `${dataset.name} unique key ${i + 1} must be unique`, + validationScope: "NONE", + })); +} - // Step 7: apply SEMARCHY custom_extensions; every other vendor's entries are - // preserved on the import side, never here. - for (const ext of osi.custom_extensions ?? []) { - if (ext.vendor_name === SEMARCHY_VENDOR) { - // TODO(schema): merge JSON.parse(ext.data) into the Semarchy model. - } +function relationshipToReference( + rel: OSIRelationship, + pkg: string, + warnings: string[], +): SemarchyReference { + if (rel.from_columns.length > 1 || rel.to_columns.length > 1) { + warnings.push( + `[warn] relationship "${rel.name}": composite foreign key flattened to ` + + `the first column (Semarchy references model a single foreign key)`, + ); } + const fromCol = rel.from_columns[0] ?? "REF"; + return { + _type: "Reference", + _package: pkg, + _name: logicalName(rel.name, warnings, "reference"), + label: rel.name, + physicalName: physicalName(rel.name), + fromEntity: logicalName(rel.from, warnings, "entity"), + toEntity: logicalName(rel.to, warnings, "entity"), + fromRoleLabel: rel.from, + fromRoleName: logicalName(rel.from, warnings, "role"), + fromRolePluralLabel: `${rel.from}s`, + toRoleLabel: rel.to, + toRoleName: logicalName(rel.to, warnings, "role"), + toRolePhysicalName: physicalName(fromCol), + deletePropagation: "RESTRICT", + validationScope: "NONE", + }; +} - // Step 8: ai_context -> Semarchy annotations. TODO(schema). +/** Convert a single OSI semantic model to a grouped Semarchy model. */ +export function osiToSemarchy( + osi: OSISemanticModel, + warnings: string[] = [], +): ExportResult { + const pkg = logicalName(osi.name, warnings, "package"); + + const entities = (osi.datasets ?? []).map((d) => + datasetToEntity(d, pkg, warnings), + ); + const uniqueKeys = (osi.datasets ?? []).flatMap((d) => + uniqueKeysOf(d, pkg, warnings), + ); + const references = (osi.relationships ?? []).map((r) => + relationshipToReference(r, pkg, warnings), + ); + + for (const metric of osi.metrics ?? []) { + warnings.push( + `[drop] metric "${metric.name}": Semarchy xDM has no measure concept`, + ); + } - return { model, warnings }; + return { model: { pkg, entities, references, uniqueKeys }, warnings }; } /** Convenience wrapper over a full OSI document (converts the first model). */ diff --git a/converters/semarchy/src/semarchy-to-osi.ts b/converters/semarchy/src/semarchy-to-osi.ts index a2d375e4..414a6211 100644 --- a/converters/semarchy/src/semarchy-to-osi.ts +++ b/converters/semarchy/src/semarchy-to-osi.ts @@ -1,40 +1,132 @@ /** * Import: Semarchy -> OSI. * - * Produces an OSI document that must validate against `core-spec/osi-schema.json`. - * Semarchy metadata with no OSI core equivalent must be captured into - * `custom_extensions` (vendor_name "SEMARCHY") so a Semarchy -> OSI -> Semarchy - * roundtrip stays faithful. + * Scope (per project decision): map Entity -> dataset, EntityAttribute -> field, + * UniqueKey -> unique_keys, Reference -> relationship. Everything else is dropped + * with a warning (lossy roundtrip is accepted). No metrics are produced: Semarchy + * xDM has no aggregate-measure concept. + * + * Cross-references use attribute `_name` (Semarchy references attributes by + * `_name`), so OSI field names are attribute `_name`s and the ANSI_SQL + * expression carries the physical column name. */ import { OSI_VERSION } from "./osi-types.js"; -import type { OSIDocument, OSISemanticModel } from "./osi-types.js"; -import type { SemarchyModel } from "./semarchy-types.js"; +import type { + OSIDataset, + OSIDocument, + OSIField, + OSIRelationship, + OSISemanticModel, +} from "./osi-types.js"; +import { TIME_DATA_TYPES } from "./semarchy-types.js"; +import type { + SemarchyEntity, + SemarchyModel, + SemarchyReference, +} from "./semarchy-types.js"; export interface ImportResult { document: OSIDocument; warnings: string[]; } -/** Convert a Semarchy model into an OSI document. */ +function attributeToField( + attr: SemarchyEntity["attributes"][number], +): OSIField { + const column = attr.physicalName || attr._name; + const field: OSIField = { + name: attr._name, + expression: { dialects: [{ dialect: "ANSI_SQL", expression: column }] }, + }; + if (attr.label) field.label = attr.label; + const description = attr.description ?? attr.documentation; + if (description) field.description = description; + if (TIME_DATA_TYPES.has(String(attr.dataType))) { + field.dimension = { is_time: true }; + } + return field; +} + +function entityToDataset( + entity: SemarchyEntity, + model: SemarchyModel, +): OSIDataset { + const dataset: OSIDataset = { + name: entity._name, + source: `${entity._package}.${entity.physicalTableName}`, + fields: entity.attributes.map((a) => attributeToField(a)), + }; + + const description = entity.description ?? entity.documentation; + if (description) dataset.description = description; + if (entity.primaryKey) dataset.primary_key = [entity.primaryKey]; + + const uniqueKeys = model.uniqueKeys + .filter((uk) => uk.entity === entity._name) + .map((uk) => uk.keyAttributes.map((k) => k.attribute)); + if (uniqueKeys.length) dataset.unique_keys = uniqueKeys; + + return dataset; +} + +function referenceToRelationship( + ref: SemarchyReference, + entitiesByName: Map, + warnings: string[], +): OSIRelationship | undefined { + const toEntity = entitiesByName.get(ref.toEntity); + const toColumn = toEntity?.primaryKey; + if (!toColumn) { + warnings.push( + `[drop] reference "${ref._name}": cannot resolve primary key of ` + + `to-entity "${ref.toEntity}"; relationship skipped`, + ); + return undefined; + } + // Semarchy expresses the FK implicitly; the foreign attribute on the many side + // is the best available "from" column, else the role name. + const foreignAttr = (ref.foreignAttribute as { _name?: string } | undefined) + ?._name; + const fromColumn = foreignAttr ?? ref.toRoleName; + if (!fromColumn) { + warnings.push( + `[drop] reference "${ref._name}": cannot resolve foreign key column; ` + + `relationship skipped`, + ); + return undefined; + } + // OSI relationships carry no description; Semarchy reference metadata beyond + // the join columns is dropped (accepted, per the minimal-scope decision). + return { + name: ref._name, + from: ref.fromEntity, + to: ref.toEntity, + from_columns: [fromColumn], + to_columns: [toColumn], + }; +} + +/** Convert a grouped Semarchy model into an OSI document. */ export function semarchyToOsi( - semarchy: SemarchyModel, + model: SemarchyModel, warnings: string[] = [], ): ImportResult { - const semanticModel: OSISemanticModel = { - name: semarchy.name, - description: semarchy.description, - datasets: [], - }; + const entitiesByName = new Map(model.entities.map((e) => [e._name, e])); + + const datasets = model.entities.map((e) => entityToDataset(e, model)); - // TODO(schema): map Semarchy entities -> datasets, attributes -> fields - // (emit expression.dialects with dialect "SEMARCHY"), relationships, - // measures -> metrics. Anything without an OSI equivalent goes into - // custom_extensions with vendor_name "SEMARCHY". + const relationships = model.references + .map((r) => referenceToRelationship(r, entitiesByName, warnings)) + .filter((r): r is OSIRelationship => r !== undefined); - const document: OSIDocument = { - version: OSI_VERSION, - semantic_model: [semanticModel], + const semanticModel: OSISemanticModel = { + name: model.pkg, + datasets, }; + if (relationships.length) semanticModel.relationships = relationships; - return { document, warnings }; + return { + document: { version: OSI_VERSION, semantic_model: [semanticModel] }, + warnings, + }; } diff --git a/converters/semarchy/src/semarchy-types.ts b/converters/semarchy/src/semarchy-types.ts index bea07adc..16f68941 100644 --- a/converters/semarchy/src/semarchy-types.ts +++ b/converters/semarchy/src/semarchy-types.ts @@ -1,19 +1,122 @@ /** - * TypeScript types for the Semarchy semantic model. + * TypeScript types for the subset of the Semarchy xDM model this converter maps. * - * TODO(schema): These are PLACEHOLDER shapes. The real definitions must be - * derived from the Semarchy semantic-model JSON Schema, which should be dropped - * into `converters/semarchy/schemas/semarchy-semantic-model-schema.json` (see - * README). Once the schema is available: - * 1. Replace these interfaces with the real Semarchy constructs. - * 2. Wire the schema into `validateSemarchy()` in `semarchy-schema.ts`. - * 3. Complete the mapping in `osi-to-semarchy.ts` / `semarchy-to-osi.ts`. + * Derived from the Semarchy model JSON Schemas (Entity.json, Reference.json, + * UniqueKey.json). Only the fields the converter reads or writes are typed here; + * `additionalProperties` on the real schemas allows more. Each object is a + * separately-serialized, `_type`-discriminated YAML document; a "model" is a + * directory of such documents (see `io.ts`). */ -/** Placeholder root object of a Semarchy semantic model. */ -export interface SemarchyModel { - name: string; +/** Built-in Semarchy attribute data types. */ +export type SemarchyBuiltInType = + | "Binary" + | "Boolean" + | "ByteInteger" + | "Date" + | "Decimal" + | "Integer" + | "LongInteger" + | "LongText" + | "ShortInteger" + | "String" + | "Timestamp" + | "UUID"; + +/** Data types Semarchy treats as time/temporal (drive OSI `dimension.is_time`). */ +export const TIME_DATA_TYPES: ReadonlySet = new Set([ + "Date", + "Timestamp", +]); + +export interface SemarchyEntityAttribute { + _type: "EntityAttribute"; + _name: string; + label: string; + /** UPPER_SNAKE physical column name. */ + physicalName: string; + dataType: SemarchyBuiltInType | string; + description?: string; + documentation?: string; + length?: number; + precision?: number; + scale?: number; + mandatory?: boolean; + [key: string]: unknown; +} + +export interface SemarchyEntity { + _type: "Entity"; + _package: string; + _name: string; + label: string; + pluralLabel: string; + physicalTableName: string; + entityType: "BASIC" | "FUZZY_MATCHED" | "ID_MATCHED"; + historizeGolden: boolean; + historizeMaster: boolean; + attributes: SemarchyEntityAttribute[]; description?: string; - // TODO(schema): entities, attributes, relationships, measures, etc. + documentation?: string; + /** `_name` of the attribute acting as primary key. */ + primaryKey?: string; [key: string]: unknown; } + +export interface SemarchyKeyAttribute { + attribute: string; +} + +export interface SemarchyUniqueKey { + _type: "UniqueKey"; + _package: string; + _name: string; + label: string; + /** `_name` of the owning entity. */ + entity: string; + keyAttributes: SemarchyKeyAttribute[]; + validationLabel: string; + validationScope: "NONE" | "POST_CONSO" | "PRE_CONSO" | "PRE_POST"; + description?: string; + [key: string]: unknown; +} + +export interface SemarchyReference { + _type: "Reference"; + _package: string; + _name: string; + label: string; + physicalName: string; + /** Many-side entity `_name`. */ + fromEntity: string; + /** One-side entity `_name`. */ + toEntity: string; + fromRoleLabel: string; + fromRoleName: string; + fromRolePluralLabel: string; + toRoleLabel: string; + toRoleName: string; + toRolePhysicalName: string; + deletePropagation: "CASCADE" | "NULLIFY" | "RESTRICT"; + validationScope: "NONE" | "POST_CONSO" | "PRE_CONSO" | "PRE_POST"; + description?: string; + [key: string]: unknown; +} + +/** Any typed Semarchy object, discriminated by `_type`. */ +export type SemarchyObject = + | SemarchyEntity + | SemarchyReference + | SemarchyUniqueKey + | { _type: string; _name?: string; [key: string]: unknown }; + +/** + * A Semarchy model as consumed/produced by this converter: the mapped objects + * grouped by type, plus the package name shared by its objects. + */ +export interface SemarchyModel { + pkg: string; + entities: SemarchyEntity[]; + references: SemarchyReference[]; + uniqueKeys: SemarchyUniqueKey[]; +} diff --git a/converters/semarchy/src/validation.ts b/converters/semarchy/src/validation.ts index e360fe12..ad65d64a 100644 --- a/converters/semarchy/src/validation.ts +++ b/converters/semarchy/src/validation.ts @@ -1,27 +1,25 @@ /** * Schema validation helpers. * - * - Semarchy output is validated against the Semarchy JSON Schema (guide step 9), - * once it is present under `schemas/`. - * - OSI output is validated against `osi-schema.json` (guide step 1 analogue). + * - OSI output (import) is validated against `schemas/osi-schema.json` + * (JSON Schema draft 2020-12 -> Ajv2020). + * - Semarchy output (export) is validated per object against the matching + * `schemas/.json` (draft-07 -> default Ajv). * - * Both are best-effort: if a schema file is not found, validation is skipped - * with a warning rather than failing, so the scaffold is usable before the - * Semarchy schema is dropped in. + * Best-effort: if a schema file is missing, validation is skipped with a + * warning rather than failing. */ import { existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { dirname, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import Ajv from "ajv"; +import Ajv2020 from "ajv/dist/2020.js"; +import type { SemarchyModel, SemarchyObject } from "./semarchy-types.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SCHEMAS_DIR = resolve(__dirname, "..", "schemas"); -export const SEMARCHY_SCHEMA_PATH = resolve( - SCHEMAS_DIR, - "semarchy-semantic-model-schema.json", -); -export const OSI_SCHEMA_PATH = resolve(SCHEMAS_DIR, "osi-schema.json"); +export const OSI_SCHEMA_PATH = join(SCHEMAS_DIR, "osi-schema.json"); export interface ValidationOutcome { valid: boolean; @@ -29,28 +27,55 @@ export interface ValidationOutcome { skipped: boolean; } -function validateAgainst(schemaPath: string, data: unknown): ValidationOutcome { - if (!existsSync(schemaPath)) { - return { - valid: true, - skipped: true, - errors: [`schema not found, validation skipped: ${schemaPath}`], - }; +function loadSchema(path: string): object | undefined { + return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : undefined; +} + +/** Validate the OSI document against the 2020-12 OSI schema. */ +export function validateOsi(document: unknown): ValidationOutcome { + const schema = loadSchema(OSI_SCHEMA_PATH); + if (!schema) { + return { valid: true, skipped: true, errors: [`schema not found: ${OSI_SCHEMA_PATH}`] }; } - const schema = JSON.parse(readFileSync(schemaPath, "utf8")); - const ajv = new Ajv({ allErrors: true, strict: false }); + const ajv = new Ajv2020({ allErrors: true, strict: false }); const validate = ajv.compile(schema); - const valid = validate(data) as boolean; + const valid = validate(document) as boolean; const errors = (validate.errors ?? []).map( (e) => `${e.instancePath || "/"} ${e.message ?? ""}`.trim(), ); return { valid, skipped: false, errors }; } -export function validateSemarchy(model: unknown): ValidationOutcome { - return validateAgainst(SEMARCHY_SCHEMA_PATH, model); -} +/** Validate every mapped Semarchy object against its per-type draft-07 schema. */ +export function validateSemarchy(model: SemarchyModel): ValidationOutcome { + const ajv = new Ajv({ allErrors: true, strict: false }); + const errors: string[] = []; + let checked = 0; -export function validateOsi(document: unknown): ValidationOutcome { - return validateAgainst(OSI_SCHEMA_PATH, document); + const objects: SemarchyObject[] = [ + ...model.entities, + ...model.uniqueKeys, + ...model.references, + ]; + for (const obj of objects) { + const schema = loadSchema(join(SCHEMAS_DIR, `${obj._type}.json`)); + if (!schema) continue; + checked++; + const validate = ajv.compile(schema); + // Store the result: Ajv's ValidateFunction is a type guard, and letting it + // narrow `obj` in the negative branch would collapse it to `never`. + const ok: boolean = validate(obj); + if (!ok) { + for (const e of validate.errors ?? []) { + errors.push( + `${obj._type} ${obj._name ?? ""}: ${e.instancePath || "/"} ${e.message ?? ""}`.trim(), + ); + } + } + } + + if (checked === 0) { + return { valid: true, skipped: true, errors: ["no Semarchy schemas found"] }; + } + return { valid: errors.length === 0, skipped: false, errors }; } diff --git a/converters/semarchy/tests/fixtures/semarchy-model/Entity.Customer.yaml b/converters/semarchy/tests/fixtures/semarchy-model/Entity.Customer.yaml new file mode 100644 index 00000000..f76c09ea --- /dev/null +++ b/converters/semarchy/tests/fixtures/semarchy-model/Entity.Customer.yaml @@ -0,0 +1,28 @@ +_type: Entity +_package: retail +_name: Customer +label: Customer +pluralLabel: Customers +physicalTableName: CUSTOMER +entityType: BASIC +historizeGolden: false +historizeMaster: false +primaryKey: customerId +description: A retail customer +attributes: + - _type: EntityAttribute + _name: customerId + label: Customer ID + physicalName: CUSTOMER_ID + dataType: Integer + - _type: EntityAttribute + _name: fullName + label: Full Name + physicalName: FULL_NAME + dataType: String + description: Customer full name + - _type: EntityAttribute + _name: createdAt + label: Created At + physicalName: CREATED_AT + dataType: Timestamp diff --git a/converters/semarchy/tests/fixtures/semarchy-model/Entity.Order.yaml b/converters/semarchy/tests/fixtures/semarchy-model/Entity.Order.yaml new file mode 100644 index 00000000..f8eb7eb6 --- /dev/null +++ b/converters/semarchy/tests/fixtures/semarchy-model/Entity.Order.yaml @@ -0,0 +1,26 @@ +_type: Entity +_package: retail +_name: SalesOrder +label: Sales Order +pluralLabel: Sales Orders +physicalTableName: SALES_ORDER +entityType: BASIC +historizeGolden: false +historizeMaster: false +primaryKey: orderId +attributes: + - _type: EntityAttribute + _name: orderId + label: Order ID + physicalName: ORDER_ID + dataType: Integer + - _type: EntityAttribute + _name: customerRef + label: Customer + physicalName: CUSTOMER_ID + dataType: Integer + - _type: EntityAttribute + _name: amount + label: Amount + physicalName: AMOUNT + dataType: Decimal diff --git a/converters/semarchy/tests/fixtures/semarchy-model/Form.CustomerForm.yaml b/converters/semarchy/tests/fixtures/semarchy-model/Form.CustomerForm.yaml new file mode 100644 index 00000000..a72a82dd --- /dev/null +++ b/converters/semarchy/tests/fixtures/semarchy-model/Form.CustomerForm.yaml @@ -0,0 +1,5 @@ +# A non-semantic (UI) object: must be dropped with a warning on import. +_type: Form +_package: retail +_name: CustomerForm +label: Customer Form diff --git a/converters/semarchy/tests/fixtures/semarchy-model/Reference.OrderToCustomer.yaml b/converters/semarchy/tests/fixtures/semarchy-model/Reference.OrderToCustomer.yaml new file mode 100644 index 00000000..e2ca6927 --- /dev/null +++ b/converters/semarchy/tests/fixtures/semarchy-model/Reference.OrderToCustomer.yaml @@ -0,0 +1,21 @@ +_type: Reference +_package: retail +_name: OrderToCustomer +label: Order to Customer +physicalName: ORDER_TO_CUSTOMER +fromEntity: SalesOrder +toEntity: Customer +fromRoleLabel: Orders +fromRoleName: orders +fromRolePluralLabel: Orders +toRoleLabel: Customer +toRoleName: customer +toRolePhysicalName: CUSTOMER_ID +deletePropagation: RESTRICT +validationScope: NONE +foreignAttribute: + _type: ForeignAttribute + _name: customerRef + entity: Customer + label: Customer + physicalName: CUSTOMER_ID diff --git a/converters/semarchy/tests/fixtures/semarchy-model/UniqueKey.CustomerUk.yaml b/converters/semarchy/tests/fixtures/semarchy-model/UniqueKey.CustomerUk.yaml new file mode 100644 index 00000000..0c425120 --- /dev/null +++ b/converters/semarchy/tests/fixtures/semarchy-model/UniqueKey.CustomerUk.yaml @@ -0,0 +1,9 @@ +_type: UniqueKey +_package: retail +_name: Customer_uk1 +label: Customer natural key +entity: Customer +validationLabel: Customer natural key must be unique +validationScope: NONE +keyAttributes: + - attribute: fullName diff --git a/converters/semarchy/tests/roundtrip.test.ts b/converters/semarchy/tests/roundtrip.test.ts index c5deff8e..b7a98d1a 100644 --- a/converters/semarchy/tests/roundtrip.test.ts +++ b/converters/semarchy/tests/roundtrip.test.ts @@ -3,12 +3,13 @@ import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { parse as parseYaml } from "yaml"; -import { osiDocumentToSemarchy } from "../src/osi-to-semarchy.js"; +import { osiDocumentToSemarchy, osiToSemarchy } from "../src/osi-to-semarchy.js"; import { semarchyToOsi } from "../src/semarchy-to-osi.js"; +import { readSemarchyDir } from "../src/io.js"; +import { validateOsi, validateSemarchy } from "../src/validation.js"; import type { OSIDocument } from "../src/osi-types.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); -// TPC-DS baseline model, per the create-tests / converter guides. const TPCDS = resolve( __dirname, "..", @@ -17,28 +18,85 @@ const TPCDS = resolve( "examples", "tpcds_semantic_model.yaml", ); +const FIXTURE_DIR = resolve(__dirname, "fixtures", "semarchy-model"); function loadTpcds(): OSIDocument { return parseYaml(readFileSync(TPCDS, "utf8")) as OSIDocument; } -describe("OSI <> Semarchy", () => { - it("exports the TPC-DS baseline without throwing", () => { +describe("export OSI -> Semarchy", () => { + it("maps the TPC-DS baseline and produces schema-valid Semarchy objects", () => { const doc = loadTpcds(); - const { model } = osiDocumentToSemarchy(doc); - expect(model.name).toBe(doc.semantic_model[0]!.name); + const { model, warnings } = osiDocumentToSemarchy(doc); + + expect(model.entities).toHaveLength(doc.semantic_model[0]!.datasets.length); + + const outcome = validateSemarchy(model); + expect(outcome.skipped).toBe(false); + expect(outcome.errors).toEqual([]); + expect(outcome.valid).toBe(true); + + // TPC-DS has metrics; Semarchy has no measure concept, so they are dropped. + expect(warnings.some((w) => w.startsWith("[drop] metric"))).toBe(true); + }); +}); + +describe("import Semarchy -> OSI", () => { + it("maps the fixture directory to a schema-valid OSI document", () => { + const warnings: string[] = []; + const model = readSemarchyDir(FIXTURE_DIR, warnings); + const { document } = semarchyToOsi(model, warnings); + + expect(document.semantic_model[0]!.name).toBe("retail"); + expect(document.semantic_model[0]!.datasets.map((d) => d.name).sort()).toEqual([ + "Customer", + "SalesOrder", + ]); + // Non-semantic Form object is dropped with a warning. + expect(warnings.some((w) => w.includes('unsupported Semarchy type "Form"'))).toBe(true); + + const outcome = validateOsi(document); + expect(outcome.skipped).toBe(false); + expect(outcome.errors).toEqual([]); + expect(outcome.valid).toBe(true); + }); + + it("maps a temporal attribute to a time dimension", () => { + const model = readSemarchyDir(FIXTURE_DIR); + const { document } = semarchyToOsi(model); + const customer = document.semantic_model[0]!.datasets.find( + (d) => d.name === "Customer", + )!; + const createdAt = customer.fields!.find((f) => f.name === "createdAt")!; + expect(createdAt.dimension?.is_time).toBe(true); }); +}); + +describe("roundtrip Semarchy -> OSI -> Semarchy", () => { + it("preserves the core ER structure (entities, references, unique keys)", () => { + const original = readSemarchyDir(FIXTURE_DIR); + const { document } = semarchyToOsi(original); + const { model: roundtripped } = osiToSemarchy(document.semantic_model[0]!); + + expect(roundtripped.entities.map((e) => e._name).sort()).toEqual( + original.entities.map((e) => e._name).sort(), + ); + expect(roundtripped.references.map((r) => r._name)).toEqual( + original.references.map((r) => r._name), + ); + expect(roundtripped.uniqueKeys.map((u) => u.entity)).toEqual( + original.uniqueKeys.map((u) => u.entity), + ); + + // Attribute sets survive per entity. + for (const entity of original.entities) { + const rt = roundtripped.entities.find((e) => e._name === entity._name)!; + expect(rt.attributes.map((a) => a._name).sort()).toEqual( + entity.attributes.map((a) => a._name).sort(), + ); + } - // TODO(schema): enable once the Semarchy JSON Schema and the per-construct - // mapping are in place. A full Semarchy -> OSI -> Semarchy roundtrip must be - // information-preserving (custom_extensions for anything without an OSI core - // equivalent), per the converter guide. - it.todo("roundtrips Semarchy -> OSI -> Semarchy without information loss"); - - it("import of an empty Semarchy model yields a valid OSI envelope", () => { - const { document } = semarchyToOsi({ name: "empty" }); - expect(document.version).toBe("0.2.0.dev0"); - expect(document.semantic_model).toHaveLength(1); - expect(document.semantic_model[0]!.name).toBe("empty"); + // The roundtripped Semarchy model still validates. + expect(validateSemarchy(roundtripped).valid).toBe(true); }); }); From 70bbc69f41a078b619a81cb6eba0a20c49a7e63a Mon Sep 17 00:00:00 2001 From: Arnaud Mergey Date: Fri, 3 Jul 2026 15:09:44 +0200 Subject: [PATCH 3/7] Handle real Semarchy .seml model format on import Validated against a real Semarchy model (dxp-demo ProductRetail); adjust the importer to the conventions that differ from the bare schema: - Directory is a nested tree of .seml (YAML) files, not a flat dir of .yaml: walk recursively and accept .seml/.yaml/.yml. - Model name comes from the dedicated `Model` object (_type: Model), not the first _package; `Model` is consumed as metadata (not warned as unsupported). - References are fully qualified: primaryKey (`Pkg.entities.X.X.attr`), fromEntity/toEntity, and unique-key attributes are FQNs -> resolve to the local (last-segment) name; entity lookup for relationships is by local name. - dataType casing varies (STRING vs String, TIMESTAMP/DATE vs Timestamp/Date): is_time detection is now case-insensitive (isTimeDataType helper). - source is qualified with the model's root package (.). - Foreign key column read from reference.foreignAttribute._name. - writeSemarchyDir emits <_name>.<_type>.seml. Fixtures rewritten to mirror the real format (nested dirs, .seml, a Model object, FQN references, a UniqueKey). Roundtrip test normalizes FQN entity names. typecheck + 8 tests pass on Node 20; importing the real ProductRetail model produces OSI that passes validation/validate.py. Co-Authored-By: Claude Opus 4.8 --- converters/semarchy/src/io.ts | 69 +++++++++++-------- converters/semarchy/src/osi-to-semarchy.ts | 5 +- converters/semarchy/src/semarchy-to-osi.ts | 35 ++++++---- converters/semarchy/src/semarchy-types.ts | 42 ++++++++--- .../semarchy-model/Form.CustomerForm.yaml | 5 -- .../semarchy-model/UniqueKey.CustomerUk.yaml | 9 --- .../Customer/Customer.Entity.seml} | 10 +-- .../Customer/forms/CustomerForm.Form.seml | 5 ++ .../CustomerNaturalKey.UniqueKey.seml | 9 +++ .../SalesOrder/SalesOrder.Entity.seml} | 10 +-- .../OrderToCustomer.Reference.seml} | 15 ++-- .../fixtures/semarchy-model/retail.Model.seml | 7 ++ converters/semarchy/tests/roundtrip.test.ts | 3 +- 13 files changed, 138 insertions(+), 86 deletions(-) delete mode 100644 converters/semarchy/tests/fixtures/semarchy-model/Form.CustomerForm.yaml delete mode 100644 converters/semarchy/tests/fixtures/semarchy-model/UniqueKey.CustomerUk.yaml rename converters/semarchy/tests/fixtures/semarchy-model/{Entity.Customer.yaml => entities/Customer/Customer.Entity.seml} (77%) create mode 100644 converters/semarchy/tests/fixtures/semarchy-model/entities/Customer/forms/CustomerForm.Form.seml create mode 100644 converters/semarchy/tests/fixtures/semarchy-model/entities/Customer/unique_keys/CustomerNaturalKey.UniqueKey.seml rename converters/semarchy/tests/fixtures/semarchy-model/{Entity.Order.yaml => entities/SalesOrder/SalesOrder.Entity.seml} (74%) rename converters/semarchy/tests/fixtures/semarchy-model/{Reference.OrderToCustomer.yaml => references/OrderToCustomer.Reference.seml} (61%) create mode 100644 converters/semarchy/tests/fixtures/semarchy-model/retail.Model.seml diff --git a/converters/semarchy/src/io.ts b/converters/semarchy/src/io.ts index 4c73da13..d2a584c6 100644 --- a/converters/semarchy/src/io.ts +++ b/converters/semarchy/src/io.ts @@ -1,10 +1,13 @@ /** - * Directory-of-YAML I/O for Semarchy models. + * Directory-tree I/O for Semarchy models. * - * A Semarchy model is delivered as a directory of YAML files; each YAML - * document is one `_type`-discriminated object. On read we group the mapped - * object types into a `SemarchyModel` and warn about (drop) everything else. - * On write we emit one YAML file per object, named `<_type>.<_name>.yaml`. + * A Semarchy model is a nested directory of `.seml` (YAML) files; each file is + * one `_type`-discriminated object (entities under `entities//`, + * references under `references/`, etc.). We walk the tree recursively, group the + * mapped object types into a `SemarchyModel`, take the model name from the + * `Model` object, and warn about (drop) everything else. + * + * On write we emit one file per object, named `<_name>.<_type>.seml`. */ import { existsSync, @@ -19,6 +22,7 @@ import { parseAllDocuments, stringify as stringifyYaml } from "yaml"; import type { SemarchyEntity, SemarchyModel, + SemarchyModelObject, SemarchyObject, SemarchyReference, SemarchyUniqueKey, @@ -26,18 +30,25 @@ import type { /** Semarchy object types this converter maps; all others are dropped on read. */ const MAPPED_TYPES = new Set(["Entity", "Reference", "UniqueKey"]); +/** Types consumed as model metadata (not mapped, but not warned about). */ +const META_TYPES = new Set(["Model"]); +const MODEL_FILE_RE = /\.(seml|ya?ml)$/i; -function listYamlFiles(dir: string): string[] { +/** Recursively collect model files under a directory. */ +function walk(dir: string): string[] { if (!existsSync(dir) || !statSync(dir).isDirectory()) { throw new Error(`not a directory: ${dir}`); } - return readdirSync(dir) - .filter((f) => /\.ya?ml$/i.test(f)) - .sort() - .map((f) => join(dir, f)); + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...walk(full)); + else if (MODEL_FILE_RE.test(entry.name)) out.push(full); + } + return out.sort(); } -/** Read a directory of Semarchy YAML objects into a grouped model. */ +/** Read a directory tree of Semarchy objects into a grouped model. */ export function readSemarchyDir( dir: string, warnings: string[] = [], @@ -45,45 +56,43 @@ export function readSemarchyDir( const entities: SemarchyEntity[] = []; const references: SemarchyReference[] = []; const uniqueKeys: SemarchyUniqueKey[] = []; - const packages = new Set(); + let modelObj: SemarchyModelObject | undefined; - for (const file of listYamlFiles(dir)) { - const docs = parseAllDocuments(readFileSync(file, "utf8")); - for (const doc of docs) { + for (const file of walk(dir)) { + for (const doc of parseAllDocuments(readFileSync(file, "utf8"))) { const obj = doc.toJSON() as SemarchyObject | null; - if (!obj || typeof obj !== "object" || !("_type" in obj)) continue; + if (!obj || typeof obj !== "object" || typeof obj._type !== "string") { + continue; + } const type = obj._type; - if (typeof obj._package === "string") packages.add(obj._package); - if (!MAPPED_TYPES.has(type)) { + if (type === "Entity") entities.push(obj as SemarchyEntity); + else if (type === "Reference") references.push(obj as SemarchyReference); + else if (type === "UniqueKey") uniqueKeys.push(obj as SemarchyUniqueKey); + else if (type === "Model") modelObj = obj as SemarchyModelObject; + else if (!META_TYPES.has(type)) { warnings.push( `[drop] unsupported Semarchy type "${type}"` + (obj._name ? ` (${obj._name})` : "") + ` in ${file}`, ); - continue; } - if (type === "Entity") entities.push(obj as SemarchyEntity); - else if (type === "Reference") references.push(obj as SemarchyReference); - else if (type === "UniqueKey") uniqueKeys.push(obj as SemarchyUniqueKey); } } - if (packages.size > 1) { - warnings.push( - `[model] multiple packages found (${[...packages].join(", ")}); ` + - `using "${[...packages][0]}" as the model name`, - ); + if (!modelObj) { + warnings.push(`[model] no Model object found in ${dir}; using "model"`); } return { - pkg: [...packages][0] ?? "model", + name: modelObj?._name ?? modelObj?.label ?? "model", + pkg: modelObj?._package ?? modelObj?._name ?? "model", entities, references, uniqueKeys, }; } -/** Write a Semarchy model to a directory, one YAML file per object. */ +/** Write a Semarchy model to a directory, one `.seml` file per object. */ export function writeSemarchyDir(dir: string, model: SemarchyModel): void { mkdirSync(dir, { recursive: true }); const objects: SemarchyObject[] = [ @@ -92,7 +101,7 @@ export function writeSemarchyDir(dir: string, model: SemarchyModel): void { ...model.references, ]; for (const obj of objects) { - const name = `${obj._type}.${obj._name ?? "unnamed"}.yaml`; + const name = `${obj._name ?? "unnamed"}.${obj._type}.seml`; writeFileSync(join(dir, name), stringifyYaml(obj)); } } diff --git a/converters/semarchy/src/osi-to-semarchy.ts b/converters/semarchy/src/osi-to-semarchy.ts index a2e5d101..baf772a1 100644 --- a/converters/semarchy/src/osi-to-semarchy.ts +++ b/converters/semarchy/src/osi-to-semarchy.ts @@ -183,7 +183,10 @@ export function osiToSemarchy( ); } - return { model: { pkg, entities, references, uniqueKeys }, warnings }; + return { + model: { name: osi.name, pkg, entities, references, uniqueKeys }, + warnings, + }; } /** Convenience wrapper over a full OSI document (converts the first model). */ diff --git a/converters/semarchy/src/semarchy-to-osi.ts b/converters/semarchy/src/semarchy-to-osi.ts index 414a6211..668c813a 100644 --- a/converters/semarchy/src/semarchy-to-osi.ts +++ b/converters/semarchy/src/semarchy-to-osi.ts @@ -18,13 +18,19 @@ import type { OSIRelationship, OSISemanticModel, } from "./osi-types.js"; -import { TIME_DATA_TYPES } from "./semarchy-types.js"; +import { isTimeDataType } from "./semarchy-types.js"; import type { SemarchyEntity, SemarchyModel, SemarchyReference, } from "./semarchy-types.js"; +/** Last dot-separated segment of a Semarchy fully-qualified name. */ +function localName(fqn: string): string { + const parts = fqn.split("."); + return parts[parts.length - 1] ?? fqn; +} + export interface ImportResult { document: OSIDocument; warnings: string[]; @@ -41,7 +47,7 @@ function attributeToField( if (attr.label) field.label = attr.label; const description = attr.description ?? attr.documentation; if (description) field.description = description; - if (TIME_DATA_TYPES.has(String(attr.dataType))) { + if (isTimeDataType(String(attr.dataType))) { field.dimension = { is_time: true }; } return field; @@ -53,17 +59,19 @@ function entityToDataset( ): OSIDataset { const dataset: OSIDataset = { name: entity._name, - source: `${entity._package}.${entity.physicalTableName}`, + // Qualify the physical table with the model's root package. + source: `${model.pkg}.${entity.physicalTableName}`, fields: entity.attributes.map((a) => attributeToField(a)), }; const description = entity.description ?? entity.documentation; if (description) dataset.description = description; - if (entity.primaryKey) dataset.primary_key = [entity.primaryKey]; + // primaryKey is a FQN (`Pkg.entities.X.X.attr`); take the attribute name. + if (entity.primaryKey) dataset.primary_key = [localName(entity.primaryKey)]; const uniqueKeys = model.uniqueKeys - .filter((uk) => uk.entity === entity._name) - .map((uk) => uk.keyAttributes.map((k) => k.attribute)); + .filter((uk) => localName(uk.entity) === entity._name) + .map((uk) => uk.keyAttributes.map((k) => localName(k.attribute))); if (uniqueKeys.length) dataset.unique_keys = uniqueKeys; return dataset; @@ -74,8 +82,9 @@ function referenceToRelationship( entitiesByName: Map, warnings: string[], ): OSIRelationship | undefined { - const toEntity = entitiesByName.get(ref.toEntity); - const toColumn = toEntity?.primaryKey; + // fromEntity/toEntity are FQNs; resolve by local (last-segment) name. + const toEntity = entitiesByName.get(localName(ref.toEntity)); + const toColumn = toEntity?.primaryKey && localName(toEntity.primaryKey); if (!toColumn) { warnings.push( `[drop] reference "${ref._name}": cannot resolve primary key of ` + @@ -85,9 +94,7 @@ function referenceToRelationship( } // Semarchy expresses the FK implicitly; the foreign attribute on the many side // is the best available "from" column, else the role name. - const foreignAttr = (ref.foreignAttribute as { _name?: string } | undefined) - ?._name; - const fromColumn = foreignAttr ?? ref.toRoleName; + const fromColumn = ref.foreignAttribute?._name ?? ref.toRoleName; if (!fromColumn) { warnings.push( `[drop] reference "${ref._name}": cannot resolve foreign key column; ` + @@ -99,8 +106,8 @@ function referenceToRelationship( // the join columns is dropped (accepted, per the minimal-scope decision). return { name: ref._name, - from: ref.fromEntity, - to: ref.toEntity, + from: localName(ref.fromEntity), + to: localName(ref.toEntity), from_columns: [fromColumn], to_columns: [toColumn], }; @@ -120,7 +127,7 @@ export function semarchyToOsi( .filter((r): r is OSIRelationship => r !== undefined); const semanticModel: OSISemanticModel = { - name: model.pkg, + name: model.name, datasets, }; if (relationships.length) semanticModel.relationships = relationships; diff --git a/converters/semarchy/src/semarchy-types.ts b/converters/semarchy/src/semarchy-types.ts index 16f68941..67f3ec53 100644 --- a/converters/semarchy/src/semarchy-types.ts +++ b/converters/semarchy/src/semarchy-types.ts @@ -23,11 +23,17 @@ export type SemarchyBuiltInType = | "Timestamp" | "UUID"; -/** Data types Semarchy treats as time/temporal (drive OSI `dimension.is_time`). */ -export const TIME_DATA_TYPES: ReadonlySet = new Set([ - "Date", - "Timestamp", -]); +/** + * Data types Semarchy treats as time/temporal (drive OSI `dimension.is_time`). + * Real models use either PascalCase (`Date`) or SCREAMING_SNAKE (`DATE`), so + * detection is case-insensitive. + */ +const TIME_DATA_TYPES: ReadonlySet = new Set(["DATE", "TIMESTAMP"]); + +/** True if a (possibly qualified) Semarchy data type is temporal. */ +export function isTimeDataType(dataType: string | undefined): boolean { + return dataType !== undefined && TIME_DATA_TYPES.has(dataType.toUpperCase()); +} export interface SemarchyEntityAttribute { _type: "EntityAttribute"; @@ -58,11 +64,24 @@ export interface SemarchyEntity { attributes: SemarchyEntityAttribute[]; description?: string; documentation?: string; - /** `_name` of the attribute acting as primary key. */ + /** + * Fully-qualified reference to the primary-key attribute, e.g. + * `Pkg.entities.Item.Item.UPC`; the attribute name is the last segment. + */ primaryKey?: string; [key: string]: unknown; } +/** Root model object; carries the human model name. */ +export interface SemarchyModelObject { + _type: "Model"; + _package: string; + _name: string; + label?: string; + description?: string; + [key: string]: unknown; +} + export interface SemarchyKeyAttribute { attribute: string; } @@ -87,10 +106,12 @@ export interface SemarchyReference { _name: string; label: string; physicalName: string; - /** Many-side entity `_name`. */ + /** Fully-qualified many-side entity reference (`Pkg.entities.X.X`). */ fromEntity: string; - /** One-side entity `_name`. */ + /** Fully-qualified one-side entity reference. */ toEntity: string; + /** Foreign attribute on the many side; its `_name` is the FK column. */ + foreignAttribute?: { _name?: string; physicalName?: string; [k: string]: unknown }; fromRoleLabel: string; fromRoleName: string; fromRolePluralLabel: string; @@ -112,9 +133,12 @@ export type SemarchyObject = /** * A Semarchy model as consumed/produced by this converter: the mapped objects - * grouped by type, plus the package name shared by its objects. + * grouped by type, plus the model name/package (from the `Model` object). */ export interface SemarchyModel { + /** Human/logical model name (from the `Model` object), used as the OSI name. */ + name: string; + /** Root package (from the `Model` object), used to qualify sources. */ pkg: string; entities: SemarchyEntity[]; references: SemarchyReference[]; diff --git a/converters/semarchy/tests/fixtures/semarchy-model/Form.CustomerForm.yaml b/converters/semarchy/tests/fixtures/semarchy-model/Form.CustomerForm.yaml deleted file mode 100644 index a72a82dd..00000000 --- a/converters/semarchy/tests/fixtures/semarchy-model/Form.CustomerForm.yaml +++ /dev/null @@ -1,5 +0,0 @@ -# A non-semantic (UI) object: must be dropped with a warning on import. -_type: Form -_package: retail -_name: CustomerForm -label: Customer Form diff --git a/converters/semarchy/tests/fixtures/semarchy-model/UniqueKey.CustomerUk.yaml b/converters/semarchy/tests/fixtures/semarchy-model/UniqueKey.CustomerUk.yaml deleted file mode 100644 index 0c425120..00000000 --- a/converters/semarchy/tests/fixtures/semarchy-model/UniqueKey.CustomerUk.yaml +++ /dev/null @@ -1,9 +0,0 @@ -_type: UniqueKey -_package: retail -_name: Customer_uk1 -label: Customer natural key -entity: Customer -validationLabel: Customer natural key must be unique -validationScope: NONE -keyAttributes: - - attribute: fullName diff --git a/converters/semarchy/tests/fixtures/semarchy-model/Entity.Customer.yaml b/converters/semarchy/tests/fixtures/semarchy-model/entities/Customer/Customer.Entity.seml similarity index 77% rename from converters/semarchy/tests/fixtures/semarchy-model/Entity.Customer.yaml rename to converters/semarchy/tests/fixtures/semarchy-model/entities/Customer/Customer.Entity.seml index f76c09ea..66322b4c 100644 --- a/converters/semarchy/tests/fixtures/semarchy-model/Entity.Customer.yaml +++ b/converters/semarchy/tests/fixtures/semarchy-model/entities/Customer/Customer.Entity.seml @@ -1,5 +1,5 @@ +_package: retail.entities.Customer _type: Entity -_package: retail _name: Customer label: Customer pluralLabel: Customers @@ -7,22 +7,22 @@ physicalTableName: CUSTOMER entityType: BASIC historizeGolden: false historizeMaster: false -primaryKey: customerId description: A retail customer +primaryKey: retail.entities.Customer.Customer.customerId attributes: - _type: EntityAttribute _name: customerId label: Customer ID physicalName: CUSTOMER_ID - dataType: Integer + dataType: INTEGER - _type: EntityAttribute _name: fullName label: Full Name physicalName: FULL_NAME - dataType: String + dataType: STRING description: Customer full name - _type: EntityAttribute _name: createdAt label: Created At physicalName: CREATED_AT - dataType: Timestamp + dataType: TIMESTAMP diff --git a/converters/semarchy/tests/fixtures/semarchy-model/entities/Customer/forms/CustomerForm.Form.seml b/converters/semarchy/tests/fixtures/semarchy-model/entities/Customer/forms/CustomerForm.Form.seml new file mode 100644 index 00000000..97a614ff --- /dev/null +++ b/converters/semarchy/tests/fixtures/semarchy-model/entities/Customer/forms/CustomerForm.Form.seml @@ -0,0 +1,5 @@ +# A non-semantic (UI) object nested under an entity: dropped with a warning. +_type: Form +_package: retail.entities.Customer +_name: CustomerForm +label: Customer Form diff --git a/converters/semarchy/tests/fixtures/semarchy-model/entities/Customer/unique_keys/CustomerNaturalKey.UniqueKey.seml b/converters/semarchy/tests/fixtures/semarchy-model/entities/Customer/unique_keys/CustomerNaturalKey.UniqueKey.seml new file mode 100644 index 00000000..4146ccab --- /dev/null +++ b/converters/semarchy/tests/fixtures/semarchy-model/entities/Customer/unique_keys/CustomerNaturalKey.UniqueKey.seml @@ -0,0 +1,9 @@ +_package: retail.entities.Customer +_type: UniqueKey +_name: CustomerNaturalKey +label: Customer natural key +entity: retail.entities.Customer.Customer +validationLabel: Customer natural key must be unique +validationScope: NONE +keyAttributes: + - attribute: retail.entities.Customer.Customer.fullName diff --git a/converters/semarchy/tests/fixtures/semarchy-model/Entity.Order.yaml b/converters/semarchy/tests/fixtures/semarchy-model/entities/SalesOrder/SalesOrder.Entity.seml similarity index 74% rename from converters/semarchy/tests/fixtures/semarchy-model/Entity.Order.yaml rename to converters/semarchy/tests/fixtures/semarchy-model/entities/SalesOrder/SalesOrder.Entity.seml index f8eb7eb6..31459ebd 100644 --- a/converters/semarchy/tests/fixtures/semarchy-model/Entity.Order.yaml +++ b/converters/semarchy/tests/fixtures/semarchy-model/entities/SalesOrder/SalesOrder.Entity.seml @@ -1,5 +1,5 @@ +_package: retail.entities.SalesOrder _type: Entity -_package: retail _name: SalesOrder label: Sales Order pluralLabel: Sales Orders @@ -7,20 +7,20 @@ physicalTableName: SALES_ORDER entityType: BASIC historizeGolden: false historizeMaster: false -primaryKey: orderId +primaryKey: retail.entities.SalesOrder.SalesOrder.orderId attributes: - _type: EntityAttribute _name: orderId label: Order ID physicalName: ORDER_ID - dataType: Integer + dataType: INTEGER - _type: EntityAttribute _name: customerRef label: Customer physicalName: CUSTOMER_ID - dataType: Integer + dataType: INTEGER - _type: EntityAttribute _name: amount label: Amount physicalName: AMOUNT - dataType: Decimal + dataType: DECIMAL diff --git a/converters/semarchy/tests/fixtures/semarchy-model/Reference.OrderToCustomer.yaml b/converters/semarchy/tests/fixtures/semarchy-model/references/OrderToCustomer.Reference.seml similarity index 61% rename from converters/semarchy/tests/fixtures/semarchy-model/Reference.OrderToCustomer.yaml rename to converters/semarchy/tests/fixtures/semarchy-model/references/OrderToCustomer.Reference.seml index e2ca6927..404d1ccd 100644 --- a/converters/semarchy/tests/fixtures/semarchy-model/Reference.OrderToCustomer.yaml +++ b/converters/semarchy/tests/fixtures/semarchy-model/references/OrderToCustomer.Reference.seml @@ -1,21 +1,22 @@ +_package: retail.references _type: Reference -_package: retail _name: OrderToCustomer label: Order to Customer physicalName: ORDER_TO_CUSTOMER -fromEntity: SalesOrder -toEntity: Customer +deletePropagation: RESTRICT +oneToMany: true +fromEntity: retail.entities.SalesOrder.SalesOrder +toEntity: retail.entities.Customer.Customer fromRoleLabel: Orders -fromRoleName: orders +fromRoleName: Orders fromRolePluralLabel: Orders toRoleLabel: Customer -toRoleName: customer +toRoleName: Customer toRolePhysicalName: CUSTOMER_ID -deletePropagation: RESTRICT validationScope: NONE foreignAttribute: _type: ForeignAttribute _name: customerRef - entity: Customer label: Customer physicalName: CUSTOMER_ID + entity: retail.entities.SalesOrder.SalesOrder diff --git a/converters/semarchy/tests/fixtures/semarchy-model/retail.Model.seml b/converters/semarchy/tests/fixtures/semarchy-model/retail.Model.seml new file mode 100644 index 00000000..0a499cf0 --- /dev/null +++ b/converters/semarchy/tests/fixtures/semarchy-model/retail.Model.seml @@ -0,0 +1,7 @@ +_type: Model +_package: retail +_name: retail +label: Retail +description: Retail demo model +modelConfiguration: + type: POSTGRESQL diff --git a/converters/semarchy/tests/roundtrip.test.ts b/converters/semarchy/tests/roundtrip.test.ts index b7a98d1a..3722825c 100644 --- a/converters/semarchy/tests/roundtrip.test.ts +++ b/converters/semarchy/tests/roundtrip.test.ts @@ -84,8 +84,9 @@ describe("roundtrip Semarchy -> OSI -> Semarchy", () => { expect(roundtripped.references.map((r) => r._name)).toEqual( original.references.map((r) => r._name), ); + const localName = (fqn: string) => fqn.split(".").pop(); expect(roundtripped.uniqueKeys.map((u) => u.entity)).toEqual( - original.uniqueKeys.map((u) => u.entity), + original.uniqueKeys.map((u) => localName(u.entity)), ); // Attribute sets survive per entity. From 2398666cf4897925888dc0b5871b94204e60d8b0 Mon Sep 17 00:00:00 2001 From: Arnaud Mergey Date: Fri, 3 Jul 2026 15:10:15 +0200 Subject: [PATCH 4/7] docs(semarchy): describe real .seml nested-tree model format Co-Authored-By: Claude Opus 4.8 --- converters/semarchy/README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/converters/semarchy/README.md b/converters/semarchy/README.md index c34d87a3..e76950be 100644 --- a/converters/semarchy/README.md +++ b/converters/semarchy/README.md @@ -12,10 +12,13 @@ files. ## Model shape -A Semarchy model is a **directory of YAML files**, each file one -`_type`-discriminated object (`Entity`, `Reference`, `UniqueKey`, …). The OSI -side is a single YAML document. So export writes a directory, and import reads -one. +A Semarchy model is a **nested directory tree of `.seml` (YAML) files**, each +file one `_type`-discriminated object (entities under `entities//`, +references under `references/`, …), plus a root `Model` object. Import walks the +tree recursively (accepting `.seml`, `.yaml`, `.yml`), takes the model name from +the `Model` object, and resolves the fully-qualified references Semarchy uses +(`Pkg.entities.Item.Item.UPC`) down to local names. The OSI side is a single +YAML document. Export writes a flat directory of `..seml` files. ## Install & build @@ -32,11 +35,11 @@ Requires Node ≥ 18 (declared in `engines`). ## CLI ```bash -# OSI (YAML file) -> Semarchy (directory of YAML) +# OSI (YAML file) -> Semarchy (directory of .seml) osi-semarchy osi-to-semarchy -i model.yaml -o semarchy-dir/ -# Semarchy (directory of YAML) -> OSI (YAML file) -osi-semarchy semarchy-to-osi -i semarchy-dir/ -o model.yaml +# Semarchy (directory tree of .seml) -> OSI (YAML file) +osi-semarchy semarchy-to-osi -i semarchy-model-src/ -o model.yaml ``` ## Library From e3dc766444f276397a8fae467100f2091ff8e40f Mon Sep 17 00:00:00 2001 From: Arnaud Mergey Date: Fri, 3 Jul 2026 15:21:30 +0200 Subject: [PATCH 5/7] Export the native Semarchy tree (Model, FQN, nested dirs) Make OSI -> Semarchy export produce a re-importable native model instead of flat bare-name files: - Generate a root `Model` object (_type: Model) carrying the model name. - Regenerate Semarchy fully-qualified names: entity packages (.entities.), primaryKey (.entities...), reference fromEntity/toEntity and foreignAttribute.entity, and unique-key entity/attribute references. - Emit a reference foreignAttribute (the FK column) so imports resolve it. - writeSemarchyDir writes the native tree: .Model.seml at the root, entities//.Entity.seml, entities//unique_keys/, and references/.Reference.seml. Added a file-based export->reimport test. Verified a full roundtrip on the real ProductRetail demo model: the generated tree mirrors the native layout (same file paths and FQNs), re-imports, and the re-imported OSI passes validation/validate.py with all 8 datasets and 9 relationships preserved. typecheck + 9 tests pass on Node 20. Co-Authored-By: Claude Opus 4.8 --- converters/semarchy/README.md | 5 +- converters/semarchy/src/io.ts | 39 ++++++++++---- converters/semarchy/src/osi-to-semarchy.ts | 58 +++++++++++++++++---- converters/semarchy/src/semarchy-types.ts | 2 + converters/semarchy/tests/roundtrip.test.ts | 40 ++++++++++++-- 5 files changed, 118 insertions(+), 26 deletions(-) diff --git a/converters/semarchy/README.md b/converters/semarchy/README.md index e76950be..4d4b3d2d 100644 --- a/converters/semarchy/README.md +++ b/converters/semarchy/README.md @@ -18,7 +18,10 @@ references under `references/`, …), plus a root `Model` object. Import walks t tree recursively (accepting `.seml`, `.yaml`, `.yml`), takes the model name from the `Model` object, and resolves the fully-qualified references Semarchy uses (`Pkg.entities.Item.Item.UPC`) down to local names. The OSI side is a single -YAML document. Export writes a flat directory of `..seml` files. +YAML document. Export writes the same native tree — a root `Model` object, +`entities//.Entity.seml`, `entities//unique_keys/…`, and +`references/.Reference.seml`, with fully-qualified references regenerated — +so the output re-imports cleanly (verified on the real ProductRetail model). ## Install & build diff --git a/converters/semarchy/src/io.ts b/converters/semarchy/src/io.ts index d2a584c6..6c5e9868 100644 --- a/converters/semarchy/src/io.ts +++ b/converters/semarchy/src/io.ts @@ -92,16 +92,37 @@ export function readSemarchyDir( }; } -/** Write a Semarchy model to a directory, one `.seml` file per object. */ +/** Last dot-separated segment of a Semarchy fully-qualified name. */ +function localName(fqn: string): string { + const parts = fqn.split("."); + return parts[parts.length - 1] ?? fqn; +} + +function writeObject(dir: string, obj: SemarchyObject): void { + mkdirSync(dir, { recursive: true }); + const file = `${obj._name ?? "unnamed"}.${obj._type}.seml`; + writeFileSync(join(dir, file), stringifyYaml(obj)); +} + +/** + * Write a Semarchy model to a directory tree mirroring the native layout: + * .Model.seml + * entities//.Entity.seml + * entities//unique_keys/.UniqueKey.seml + * references/.Reference.seml + */ export function writeSemarchyDir(dir: string, model: SemarchyModel): void { mkdirSync(dir, { recursive: true }); - const objects: SemarchyObject[] = [ - ...model.entities, - ...model.uniqueKeys, - ...model.references, - ]; - for (const obj of objects) { - const name = `${obj._name ?? "unnamed"}.${obj._type}.seml`; - writeFileSync(join(dir, name), stringifyYaml(obj)); + if (model.modelObject) writeObject(dir, model.modelObject); + + for (const entity of model.entities) { + writeObject(join(dir, "entities", entity._name), entity); + } + for (const uk of model.uniqueKeys) { + const entity = localName(uk.entity); + writeObject(join(dir, "entities", entity, "unique_keys"), uk); + } + for (const ref of model.references) { + writeObject(join(dir, "references"), ref); } } diff --git a/converters/semarchy/src/osi-to-semarchy.ts b/converters/semarchy/src/osi-to-semarchy.ts index baf772a1..70a216e7 100644 --- a/converters/semarchy/src/osi-to-semarchy.ts +++ b/converters/semarchy/src/osi-to-semarchy.ts @@ -20,6 +20,7 @@ import type { SemarchyEntity, SemarchyEntityAttribute, SemarchyModel, + SemarchyModelObject, SemarchyReference, SemarchyUniqueKey, } from "./semarchy-types.js"; @@ -29,6 +30,20 @@ export interface ExportResult { warnings: string[]; } +// --- Fully-qualified name helpers (mirror Semarchy's FQN conventions) --- +/** Package of an entity: `.entities.`. */ +function entityPackage(pkg: string, entity: string): string { + return `${pkg}.entities.${entity}`; +} +/** FQN of an entity: `.entities..`. */ +function entityFqn(pkg: string, entity: string): string { + return `${entityPackage(pkg, entity)}.${entity}`; +} +/** FQN of an attribute: `.entities...`. */ +function attributeFqn(pkg: string, entity: string, attr: string): string { + return `${entityFqn(pkg, entity)}.${attr}`; +} + /** Semarchy `_name` pattern: ^[a-zA-Z][a-zA-Z_0-9]*$. */ function logicalName(name: string, warnings: string[], what: string): string { const cleaned = name.replace(/[^a-zA-Z0-9_]/g, "_").replace(/^([^a-zA-Z])/, "n$1"); @@ -81,11 +96,12 @@ function datasetToEntity( } // source is `db.schema.table`; the physical table name is the last segment. const table = dataset.source.split(".").pop() ?? dataset.name; + const name = logicalName(dataset.name, warnings, "entity"); const entity: SemarchyEntity = { _type: "Entity", - _package: pkg, - _name: logicalName(dataset.name, warnings, "entity"), + _package: entityPackage(pkg, name), + _name: name, label: dataset.name, pluralLabel: `${dataset.name}s`, physicalTableName: physicalName(table), @@ -104,7 +120,8 @@ function datasetToEntity( `(Semarchy uses a single primary-key attribute)`, ); } - entity.primaryKey = logicalName(dataset.primary_key[0]!, warnings, "attribute"); + const pk = logicalName(dataset.primary_key[0]!, warnings, "attribute"); + entity.primaryKey = attributeFqn(pkg, name, pk); } return entity; @@ -115,14 +132,15 @@ function uniqueKeysOf( pkg: string, warnings: string[], ): SemarchyUniqueKey[] { + const entity = logicalName(dataset.name, warnings, "entity"); return (dataset.unique_keys ?? []).map((cols, i) => ({ _type: "UniqueKey", - _package: pkg, + _package: entityPackage(pkg, entity), _name: logicalName(`${dataset.name}_uk${i + 1}`, warnings, "unique key"), label: `${dataset.name} unique key ${i + 1}`, - entity: logicalName(dataset.name, warnings, "entity"), + entity: entityFqn(pkg, entity), keyAttributes: cols.map((c) => ({ - attribute: logicalName(c, warnings, "attribute"), + attribute: attributeFqn(pkg, entity, logicalName(c, warnings, "attribute")), })), validationLabel: `${dataset.name} unique key ${i + 1} must be unique`, validationScope: "NONE", @@ -141,22 +159,32 @@ function relationshipToReference( ); } const fromCol = rel.from_columns[0] ?? "REF"; + const from = logicalName(rel.from, warnings, "entity"); + const to = logicalName(rel.to, warnings, "entity"); + const fkName = logicalName(fromCol, warnings, "attribute"); return { _type: "Reference", - _package: pkg, + _package: `${pkg}.references`, _name: logicalName(rel.name, warnings, "reference"), label: rel.name, physicalName: physicalName(rel.name), - fromEntity: logicalName(rel.from, warnings, "entity"), - toEntity: logicalName(rel.to, warnings, "entity"), + fromEntity: entityFqn(pkg, from), + toEntity: entityFqn(pkg, to), fromRoleLabel: rel.from, fromRoleName: logicalName(rel.from, warnings, "role"), fromRolePluralLabel: `${rel.from}s`, toRoleLabel: rel.to, - toRoleName: logicalName(rel.to, warnings, "role"), + toRoleName: to, toRolePhysicalName: physicalName(fromCol), deletePropagation: "RESTRICT", validationScope: "NONE", + foreignAttribute: { + _type: "ForeignAttribute", + _name: fkName, + label: rel.to, + physicalName: physicalName(fromCol), + entity: entityFqn(pkg, from), + }, }; } @@ -183,8 +211,16 @@ export function osiToSemarchy( ); } + const modelObject: SemarchyModelObject = { + _type: "Model", + _package: pkg, + _name: pkg, + label: osi.name, + ...(osi.description ? { description: osi.description } : {}), + }; + return { - model: { name: osi.name, pkg, entities, references, uniqueKeys }, + model: { name: osi.name, pkg, modelObject, entities, references, uniqueKeys }, warnings, }; } diff --git a/converters/semarchy/src/semarchy-types.ts b/converters/semarchy/src/semarchy-types.ts index 67f3ec53..08029460 100644 --- a/converters/semarchy/src/semarchy-types.ts +++ b/converters/semarchy/src/semarchy-types.ts @@ -140,6 +140,8 @@ export interface SemarchyModel { name: string; /** Root package (from the `Model` object), used to qualify sources. */ pkg: string; + /** Root `Model` object (present on export; optional on import). */ + modelObject?: SemarchyModelObject; entities: SemarchyEntity[]; references: SemarchyReference[]; uniqueKeys: SemarchyUniqueKey[]; diff --git a/converters/semarchy/tests/roundtrip.test.ts b/converters/semarchy/tests/roundtrip.test.ts index 3722825c..99998f3d 100644 --- a/converters/semarchy/tests/roundtrip.test.ts +++ b/converters/semarchy/tests/roundtrip.test.ts @@ -1,11 +1,12 @@ -import { readFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; -import { dirname, resolve } from "node:path"; -import { describe, expect, it } from "vitest"; +import { dirname, join, resolve } from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; import { parse as parseYaml } from "yaml"; import { osiDocumentToSemarchy, osiToSemarchy } from "../src/osi-to-semarchy.js"; import { semarchyToOsi } from "../src/semarchy-to-osi.js"; -import { readSemarchyDir } from "../src/io.js"; +import { readSemarchyDir, writeSemarchyDir } from "../src/io.js"; import { validateOsi, validateSemarchy } from "../src/validation.js"; import type { OSIDocument } from "../src/osi-types.js"; @@ -85,7 +86,7 @@ describe("roundtrip Semarchy -> OSI -> Semarchy", () => { original.references.map((r) => r._name), ); const localName = (fqn: string) => fqn.split(".").pop(); - expect(roundtripped.uniqueKeys.map((u) => u.entity)).toEqual( + expect(roundtripped.uniqueKeys.map((u) => localName(u.entity))).toEqual( original.uniqueKeys.map((u) => localName(u.entity)), ); @@ -101,3 +102,32 @@ describe("roundtrip Semarchy -> OSI -> Semarchy", () => { expect(validateSemarchy(roundtripped).valid).toBe(true); }); }); + +describe("export writes a re-importable Semarchy directory tree", () => { + const tmp = mkdtempSync(join(tmpdir(), "osi-semarchy-")); + afterAll(() => rmSync(tmp, { recursive: true, force: true })); + + it("emits a Model object, nested entities/, and references/ that re-import", () => { + const original = readSemarchyDir(FIXTURE_DIR); + const { document } = semarchyToOsi(original); + const { model } = osiToSemarchy(document.semantic_model[0]!); + + writeSemarchyDir(tmp, model); + const reread = readSemarchyDir(tmp); + + // The Model object round-trips (its _name is the OSI model name). + expect(reread.name).toBe(document.semantic_model[0]!.name); + expect(reread.entities.map((e) => e._name).sort()).toEqual( + original.entities.map((e) => e._name).sort(), + ); + expect(reread.references.map((r) => r._name)).toEqual( + original.references.map((r) => r._name), + ); + // FQN references survive: the reference resolves back to a relationship. + const { document: reDoc } = semarchyToOsi(reread); + expect(reDoc.semantic_model[0]!.relationships?.map((r) => `${r.from}->${r.to}`)).toEqual( + document.semantic_model[0]!.relationships?.map((r) => `${r.from}->${r.to}`), + ); + expect(validateOsi(reDoc).valid).toBe(true); + }); +}); From 4681cd29caa6c2cd365d4fa8284fa87aaca5a9ca Mon Sep 17 00:00:00 2001 From: Arnaud Mergey Date: Fri, 3 Jul 2026 18:49:33 +0200 Subject: [PATCH 6/7] Map SemQLEnricher to computed fields via the SEMARCHY dialect Implement the SemQLEnricher <-> OSI computed-field mapping, the intended use of the SEMARCHY dialect (SemQL is not SQL and is carried verbatim, not translated). Import (Semarchy -> OSI): each SemQLEnricher expression {attributeName, expression} adds a SEMARCHY dialect entry to the matching field, keeping its ANSI_SQL column reference (multi-dialect, lossless). Unmatched attributes get a SEMARCHY-only field (warned). The enricher-level `condition` has no OSI equivalent and is dropped with a warning. Export (OSI -> Semarchy): fields carrying a SEMARCHY expression are grouped back into one SemQLEnricher per entity. Crucially, the entity attribute's physicalName now comes from the ANSI_SQL expression, never the SemQL, so a computed field no longer mangles the SemQL into a column name. Enrichers are written under entities//enrichers/. SemQLEnricher schema bundled and validated. Fixture gains a Customer enricher (with a dropped condition). typecheck + 11 tests pass; CLI roundtrip on the fixture shows fullName carrying both ANSI_SQL (FULL_NAME) and SEMARCHY (UPPER(fullName)), and export regenerating the enricher while keeping the physical column. Co-Authored-By: Claude Opus 4.8 --- converters/semarchy/README.md | 19 +-- .../semarchy/schemas/SemQLEnricher.json | 114 ++++++++++++++++++ converters/semarchy/src/io.ts | 11 +- converters/semarchy/src/osi-to-semarchy.ts | 58 ++++++++- converters/semarchy/src/semarchy-to-osi.ts | 51 ++++++++ converters/semarchy/src/semarchy-types.ts | 24 ++++ converters/semarchy/src/validation.ts | 1 + .../CustomerEnricher.SemQLEnricher.seml | 10 ++ converters/semarchy/tests/roundtrip.test.ts | 39 ++++++ 9 files changed, 310 insertions(+), 17 deletions(-) create mode 100644 converters/semarchy/schemas/SemQLEnricher.json create mode 100644 converters/semarchy/tests/fixtures/semarchy-model/entities/Customer/enrichers/CustomerEnricher.SemQLEnricher.seml diff --git a/converters/semarchy/README.md b/converters/semarchy/README.md index 4d4b3d2d..3b8b5ccd 100644 --- a/converters/semarchy/README.md +++ b/converters/semarchy/README.md @@ -65,13 +65,17 @@ import { | `Entity.primaryKey` | `dataset.primary_key` | | | `UniqueKey` | `dataset.unique_keys` | matched by owning entity; composite order preserved | | `Reference` | `relationship` | `fromEntity` = many side → `from`; `toEntity` = one side → `to`; FK column from the foreign attribute / role name | +| `SemQLEnricher` | computed `field` | each `{attributeName, expression}` adds a `SEMARCHY` dialect entry (the SemQL) to the field, keeping its `ANSI_SQL` column reference; enricher `condition` is dropped | ## Design notes -- **Dialect selection** uses the OSI fallback chain: prefer the `SEMARCHY` - dialect, fall back to `ANSI_SQL`, warn if neither is present. `SEMARCHY` was - added to the core-spec `Dialect` enum for this converter; it is reserved for a - future SemQL mapping (see limitations). +- **Computed fields / SemQL**: a `SemQLEnricher` expression is carried on the + OSI field in the `SEMARCHY` dialect (SemQL is *not* SQL and is not translated), + alongside the field's `ANSI_SQL` physical-column reference. On export, fields + carrying a `SEMARCHY` expression are grouped back into one `SemQLEnricher` per + entity, and the physical column always comes from the `ANSI_SQL` expression + (never the SemQL). `SEMARCHY` was added to the core-spec `Dialect` enum for + this purpose. - **Validation**: OSI output is validated against `schemas/osi-schema.json` (draft 2020-12); each emitted Semarchy object is validated against its per-type schema in `schemas/` (draft-07). Best-effort — skipped with a warning @@ -85,10 +89,9 @@ following are **dropped with a warning**, not preserved in `custom_extensions`: - **Metrics** — OSI metrics have no Semarchy equivalent (no measure concept), so they are dropped on export. -- **SemQL constructs** — `SemQLEnricher` (computed attributes), matchers, and - survivorship rules are not mapped. Consequently, imported field expressions - are plain `ANSI_SQL` column references; the `SEMARCHY` dialect is registered - but not yet emitted. +- **SemQL constructs** — `SemQLEnricher` computed attributes *are* mapped (see + Design notes), but the enricher-level `condition`, matchers, and survivorship + rules are dropped. - **Type definitions** — `ComplexType`, `UserDefinedType`, `LOVType` are dropped. - **Views** — `DatabaseView`, `BusinessView` are dropped. - **UI / process objects** — `Form`, `SearchForm`, `Stepper`, `Workflow`, diff --git a/converters/semarchy/schemas/SemQLEnricher.json b/converters/semarchy/schemas/SemQLEnricher.json new file mode 100644 index 00000000..5190c439 --- /dev/null +++ b/converters/semarchy/schemas/SemQLEnricher.json @@ -0,0 +1,114 @@ +{ + "description": "SemQL based enricher", + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "EnricherScope": { + "anyOf": [ + { + "description": "Never enrich data in integration jobs", + "const": "NONE", + "type": "string" + }, + { + "description": "Enrich data after consolidation", + "const": "POST_CONSO", + "type": "string" + }, + { + "description": "Enrich source data before consolidation", + "const": "PRE_CONSO", + "type": "string" + }, + { + "description": "Enrich data both before and after consolidation", + "const": "PRE_POST", + "type": "string" + } + ] + }, + "SemQLEnricherExpression": { + "description": "The expression used to compute the new value of an attribute in SemQL enricher", + "additionalProperties": false, + "properties": { + "attributeName": { + "description": "Name of the attribute that will take the value of the expression", + "type": "string" + }, + "expression": { + "description": "Expression to map to the attribute", + "type": "string" + } + }, + "required": [ + "attributeName", + "expression" + ], + "type": "object" + } + }, + "properties": { + "_package": { + "type": "string" + }, + "_type": { + "const": "SemQLEnricher", + "type": "string" + }, + "entity": { + "type": "string" + }, + "_name": { + "maxLength": 128, + "pattern": "^[a-zA-Z][a-zA-Z_0-9]*$", + "type": "string" + }, + "label": { + "description": "Label of the object. Used as business-friendly name", + "minLength": 1, + "type": "string" + }, + "description": { + "description": "Description of the object", + "type": "string" + }, + "afterEnrichers": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "condition": { + "description": "Optional SemQL filter to restrict the set of enriched records", + "type": "string" + }, + "enricherExecutionScope": { + "description": "Execution scope of this enricher in data certification jobs", + "$ref": "#/definitions/EnricherScope" + }, + "semQlEnricherExpressions": { + "items": { + "$ref": "#/definitions/SemQLEnricherExpression" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "_name", + "_package", + "_type", + "enricherExecutionScope", + "entity", + "label", + "semQlEnricherExpressions" + ], + "type": "object" +} \ No newline at end of file diff --git a/converters/semarchy/src/io.ts b/converters/semarchy/src/io.ts index 6c5e9868..99085480 100644 --- a/converters/semarchy/src/io.ts +++ b/converters/semarchy/src/io.ts @@ -20,6 +20,7 @@ import { import { join } from "node:path"; import { parseAllDocuments, stringify as stringifyYaml } from "yaml"; import type { + SemarchyEnricher, SemarchyEntity, SemarchyModel, SemarchyModelObject, @@ -27,9 +28,6 @@ import type { SemarchyReference, SemarchyUniqueKey, } from "./semarchy-types.js"; - -/** Semarchy object types this converter maps; all others are dropped on read. */ -const MAPPED_TYPES = new Set(["Entity", "Reference", "UniqueKey"]); /** Types consumed as model metadata (not mapped, but not warned about). */ const META_TYPES = new Set(["Model"]); const MODEL_FILE_RE = /\.(seml|ya?ml)$/i; @@ -56,6 +54,7 @@ export function readSemarchyDir( const entities: SemarchyEntity[] = []; const references: SemarchyReference[] = []; const uniqueKeys: SemarchyUniqueKey[] = []; + const enrichers: SemarchyEnricher[] = []; let modelObj: SemarchyModelObject | undefined; for (const file of walk(dir)) { @@ -68,6 +67,7 @@ export function readSemarchyDir( if (type === "Entity") entities.push(obj as SemarchyEntity); else if (type === "Reference") references.push(obj as SemarchyReference); else if (type === "UniqueKey") uniqueKeys.push(obj as SemarchyUniqueKey); + else if (type === "SemQLEnricher") enrichers.push(obj as SemarchyEnricher); else if (type === "Model") modelObj = obj as SemarchyModelObject; else if (!META_TYPES.has(type)) { warnings.push( @@ -89,6 +89,7 @@ export function readSemarchyDir( entities, references, uniqueKeys, + enrichers, }; } @@ -122,6 +123,10 @@ export function writeSemarchyDir(dir: string, model: SemarchyModel): void { const entity = localName(uk.entity); writeObject(join(dir, "entities", entity, "unique_keys"), uk); } + for (const enricher of model.enrichers) { + const entity = localName(enricher.entity); + writeObject(join(dir, "entities", entity, "enrichers"), enricher); + } for (const ref of model.references) { writeObject(join(dir, "references"), ref); } diff --git a/converters/semarchy/src/osi-to-semarchy.ts b/converters/semarchy/src/osi-to-semarchy.ts index 70a216e7..021c00dc 100644 --- a/converters/semarchy/src/osi-to-semarchy.ts +++ b/converters/semarchy/src/osi-to-semarchy.ts @@ -8,7 +8,6 @@ * are filled with sensible defaults so the output validates against the * Semarchy schemas. */ -import { selectExpression } from "./dialect.js"; import type { OSIDataset, OSIDocument, @@ -17,6 +16,7 @@ import type { OSISemanticModel, } from "./osi-types.js"; import type { + SemarchyEnricher, SemarchyEntity, SemarchyEntityAttribute, SemarchyModel, @@ -64,14 +64,19 @@ function dataTypeOf(field: OSIField): string { return field.dimension?.is_time ? "Timestamp" : "String"; } +/** The expression for a given dialect on a field, if present. */ +function dialectExpression(field: OSIField, dialect: string): string | undefined { + return field.expression.dialects.find((d) => d.dialect === dialect)?.expression; +} + function fieldToAttribute( field: OSIField, warnings: string[], ): SemarchyEntityAttribute { - // The physical column comes from the selected expression when it is a bare - // column reference; otherwise fall back to the field name. - const sel = selectExpression(field.expression, `field ${field.name}`, warnings); - const column = sel?.expression ?? field.name; + // The physical column is the ANSI_SQL (column-reference) expression, NOT the + // SEMARCHY one — a SEMARCHY expression is a SemQL computation and becomes an + // enricher, not a physical column name. + const column = dialectExpression(field, "ANSI_SQL") ?? field.name; return { _type: "EntityAttribute", _name: logicalName(field.name, warnings, "attribute"), @@ -188,6 +193,36 @@ function relationshipToReference( }; } +/** + * Build a SemQL enricher for a dataset from its computed fields — those that + * carry a SEMARCHY dialect expression. Returns undefined when there are none. + */ +function enricherFor( + dataset: OSIDataset, + pkg: string, + warnings: string[], +): SemarchyEnricher | undefined { + const expressions = (dataset.fields ?? []) + .map((f) => ({ f, semql: dialectExpression(f, "SEMARCHY") })) + .filter((x): x is { f: OSIField; semql: string } => x.semql !== undefined) + .map(({ f, semql }) => ({ + attributeName: logicalName(f.name, warnings, "attribute"), + expression: semql, + })); + if (expressions.length === 0) return undefined; + + const entity = logicalName(dataset.name, warnings, "entity"); + return { + _type: "SemQLEnricher", + _package: entityPackage(pkg, entity), + _name: `${entity}Enricher`, + label: `${dataset.name} enricher`, + entity: entityFqn(pkg, entity), + enricherExecutionScope: "PRE_CONSO", + semQlEnricherExpressions: expressions, + }; +} + /** Convert a single OSI semantic model to a grouped Semarchy model. */ export function osiToSemarchy( osi: OSISemanticModel, @@ -204,6 +239,9 @@ export function osiToSemarchy( const references = (osi.relationships ?? []).map((r) => relationshipToReference(r, pkg, warnings), ); + const enrichers = (osi.datasets ?? []) + .map((d) => enricherFor(d, pkg, warnings)) + .filter((e): e is SemarchyEnricher => e !== undefined); for (const metric of osi.metrics ?? []) { warnings.push( @@ -220,7 +258,15 @@ export function osiToSemarchy( }; return { - model: { name: osi.name, pkg, modelObject, entities, references, uniqueKeys }, + model: { + name: osi.name, + pkg, + modelObject, + entities, + references, + uniqueKeys, + enrichers, + }, warnings, }; } diff --git a/converters/semarchy/src/semarchy-to-osi.ts b/converters/semarchy/src/semarchy-to-osi.ts index 668c813a..0dc113d5 100644 --- a/converters/semarchy/src/semarchy-to-osi.ts +++ b/converters/semarchy/src/semarchy-to-osi.ts @@ -20,6 +20,7 @@ import type { } from "./osi-types.js"; import { isTimeDataType } from "./semarchy-types.js"; import type { + SemarchyEnricher, SemarchyEntity, SemarchyModel, SemarchyReference, @@ -113,6 +114,52 @@ function referenceToRelationship( }; } +/** + * Apply a SemQL enricher onto the OSI datasets: each `{attributeName, + * expression}` adds a SEMARCHY dialect entry to the matching field (keeping its + * ANSI_SQL column reference), turning it into a computed field. If no field + * matches, a SEMARCHY-only field is created. The enricher-level `condition` has + * no OSI equivalent and is dropped with a warning. + */ +function applyEnricher( + enricher: SemarchyEnricher, + datasetsByName: Map, + warnings: string[], +): void { + const dataset = datasetsByName.get(localName(enricher.entity)); + if (!dataset) { + warnings.push( + `[drop] enricher "${enricher._name}": entity "${enricher.entity}" not found`, + ); + return; + } + if (enricher.condition) { + warnings.push( + `[drop] enricher "${enricher._name}": SemQL condition has no OSI ` + + `equivalent and is dropped`, + ); + } + dataset.fields ??= []; + for (const { attributeName, expression } of enricher.semQlEnricherExpressions) { + const name = localName(attributeName); + const semarchy = { dialect: "SEMARCHY" as const, expression }; + const field = dataset.fields.find((f) => f.name === name); + if (field) { + // Replace any prior SEMARCHY entry, keep other dialects (e.g. ANSI_SQL). + field.expression.dialects = [ + ...field.expression.dialects.filter((d) => d.dialect !== "SEMARCHY"), + semarchy, + ]; + } else { + warnings.push( + `[warn] enricher "${enricher._name}": attribute "${name}" is not an ` + + `entity attribute; creating a computed-only field`, + ); + dataset.fields.push({ name, expression: { dialects: [semarchy] } }); + } + } +} + /** Convert a grouped Semarchy model into an OSI document. */ export function semarchyToOsi( model: SemarchyModel, @@ -121,6 +168,10 @@ export function semarchyToOsi( const entitiesByName = new Map(model.entities.map((e) => [e._name, e])); const datasets = model.entities.map((e) => entityToDataset(e, model)); + const datasetsByName = new Map(datasets.map((d) => [d.name, d])); + for (const enricher of model.enrichers) { + applyEnricher(enricher, datasetsByName, warnings); + } const relationships = model.references .map((r) => referenceToRelationship(r, entitiesByName, warnings)) diff --git a/converters/semarchy/src/semarchy-types.ts b/converters/semarchy/src/semarchy-types.ts index 08029460..55a08b80 100644 --- a/converters/semarchy/src/semarchy-types.ts +++ b/converters/semarchy/src/semarchy-types.ts @@ -124,6 +124,29 @@ export interface SemarchyReference { [key: string]: unknown; } +export interface SemarchyEnricherExpression { + /** Name of the entity attribute that takes the value of `expression`. */ + attributeName: string; + /** SemQL expression computing the attribute value. */ + expression: string; +} + +/** A SemQL enricher: computes attribute values from SemQL expressions. */ +export interface SemarchyEnricher { + _type: "SemQLEnricher"; + _package: string; + _name: string; + label: string; + /** Fully-qualified reference to the enriched entity. */ + entity: string; + enricherExecutionScope: "NONE" | "POST_CONSO" | "PRE_CONSO" | "PRE_POST"; + semQlEnricherExpressions: SemarchyEnricherExpression[]; + /** Optional SemQL filter restricting enriched records (no OSI equivalent). */ + condition?: string; + description?: string; + [key: string]: unknown; +} + /** Any typed Semarchy object, discriminated by `_type`. */ export type SemarchyObject = | SemarchyEntity @@ -145,4 +168,5 @@ export interface SemarchyModel { entities: SemarchyEntity[]; references: SemarchyReference[]; uniqueKeys: SemarchyUniqueKey[]; + enrichers: SemarchyEnricher[]; } diff --git a/converters/semarchy/src/validation.ts b/converters/semarchy/src/validation.ts index ad65d64a..62fa4df7 100644 --- a/converters/semarchy/src/validation.ts +++ b/converters/semarchy/src/validation.ts @@ -56,6 +56,7 @@ export function validateSemarchy(model: SemarchyModel): ValidationOutcome { ...model.entities, ...model.uniqueKeys, ...model.references, + ...model.enrichers, ]; for (const obj of objects) { const schema = loadSchema(join(SCHEMAS_DIR, `${obj._type}.json`)); diff --git a/converters/semarchy/tests/fixtures/semarchy-model/entities/Customer/enrichers/CustomerEnricher.SemQLEnricher.seml b/converters/semarchy/tests/fixtures/semarchy-model/entities/Customer/enrichers/CustomerEnricher.SemQLEnricher.seml new file mode 100644 index 00000000..6a2558d0 --- /dev/null +++ b/converters/semarchy/tests/fixtures/semarchy-model/entities/Customer/enrichers/CustomerEnricher.SemQLEnricher.seml @@ -0,0 +1,10 @@ +_package: retail.entities.Customer +_type: SemQLEnricher +_name: CustomerEnricher +label: Customer enricher +entity: retail.entities.Customer.Customer +enricherExecutionScope: PRE_CONSO +condition: fullName IS NOT NULL +semQlEnricherExpressions: + - attributeName: fullName + expression: UPPER(fullName) diff --git a/converters/semarchy/tests/roundtrip.test.ts b/converters/semarchy/tests/roundtrip.test.ts index 99998f3d..b65af29d 100644 --- a/converters/semarchy/tests/roundtrip.test.ts +++ b/converters/semarchy/tests/roundtrip.test.ts @@ -73,6 +73,45 @@ describe("import Semarchy -> OSI", () => { }); }); +describe("SemQL enricher <-> computed field", () => { + it("import adds a SEMARCHY dialect to the enriched field, keeping ANSI_SQL", () => { + const warnings: string[] = []; + const model = readSemarchyDir(FIXTURE_DIR, warnings); + const { document } = semarchyToOsi(model, warnings); + + const fullName = document.semantic_model[0]!.datasets + .find((d) => d.name === "Customer")! + .fields!.find((f) => f.name === "fullName")!; + const byDialect = Object.fromEntries( + fullName.expression.dialects.map((d) => [d.dialect, d.expression]), + ); + expect(byDialect.ANSI_SQL).toBe("FULL_NAME"); // physical column preserved + expect(byDialect.SEMARCHY).toBe("UPPER(fullName)"); // SemQL computation + + // The enricher-level condition has no OSI equivalent and is dropped. + expect(warnings.some((w) => w.includes("SemQL condition"))).toBe(true); + }); + + it("export regenerates a SemQLEnricher from the computed field", () => { + const model = readSemarchyDir(FIXTURE_DIR); + const { document } = semarchyToOsi(model); + const { model: exported } = osiToSemarchy(document.semantic_model[0]!); + + expect(exported.enrichers).toHaveLength(1); + const enricher = exported.enrichers[0]!; + expect(enricher.entity).toBe("retail.entities.Customer.Customer"); + expect(enricher.semQlEnricherExpressions).toEqual([ + { attributeName: "fullName", expression: "UPPER(fullName)" }, + ]); + // The enriched attribute keeps its physical column (not the SemQL). + const customer = exported.entities.find((e) => e._name === "Customer")!; + expect(customer.attributes.find((a) => a._name === "fullName")!.physicalName).toBe( + "FULL_NAME", + ); + expect(validateSemarchy(exported).valid).toBe(true); + }); +}); + describe("roundtrip Semarchy -> OSI -> Semarchy", () => { it("preserves the core ER structure (entities, references, unique keys)", () => { const original = readSemarchyDir(FIXTURE_DIR); From e0757fd004ed4055cd0c8bf407fa4b87dd67703b Mon Sep 17 00:00:00 2001 From: Arnaud Mergey Date: Fri, 3 Jul 2026 19:05:08 +0200 Subject: [PATCH 7/7] Make SemQLEnricher import-drop / export-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An enricher is a data-preparation rule: once it runs, the MDM column holds the materialized value, so a consumer of the model sees a plain column. Turning it into a query-time OSI expression on import over-states it. - Import: enrichers are dropped with a warning; the enriched field keeps only its ANSI_SQL column reference (no SEMARCHY expression attached). - Export: unchanged — a field carrying a SEMARCHY dialect expression is still grouped into a SemQLEnricher per entity, with physicalName taken from ANSI_SQL. Tests updated: import asserts the field stays a plain column and the enricher is dropped; export is now exercised from a hand-authored OSI model that carries a SEMARCHY expression. README and memory updated. typecheck + 11 tests pass. Co-Authored-By: Claude Opus 4.8 --- converters/semarchy/README.md | 25 ++++---- converters/semarchy/src/semarchy-to-osi.ts | 58 +++---------------- converters/semarchy/tests/roundtrip.test.ts | 63 +++++++++++++-------- 3 files changed, 61 insertions(+), 85 deletions(-) diff --git a/converters/semarchy/README.md b/converters/semarchy/README.md index 3b8b5ccd..0c6ddb0e 100644 --- a/converters/semarchy/README.md +++ b/converters/semarchy/README.md @@ -65,17 +65,21 @@ import { | `Entity.primaryKey` | `dataset.primary_key` | | | `UniqueKey` | `dataset.unique_keys` | matched by owning entity; composite order preserved | | `Reference` | `relationship` | `fromEntity` = many side → `from`; `toEntity` = one side → `to`; FK column from the foreign attribute / role name | -| `SemQLEnricher` | computed `field` | each `{attributeName, expression}` adds a `SEMARCHY` dialect entry (the SemQL) to the field, keeping its `ANSI_SQL` column reference; enricher `condition` is dropped | +| `SemQLEnricher` | — (export only) | **Import drops it**: the MDM materializes the computed value in the column, so the field stays a plain column. **Export** turns any `SEMARCHY`-dialect field expression back into a `SemQLEnricher` | ## Design notes -- **Computed fields / SemQL**: a `SemQLEnricher` expression is carried on the - OSI field in the `SEMARCHY` dialect (SemQL is *not* SQL and is not translated), - alongside the field's `ANSI_SQL` physical-column reference. On export, fields - carrying a `SEMARCHY` expression are grouped back into one `SemQLEnricher` per - entity, and the physical column always comes from the `ANSI_SQL` expression - (never the SemQL). `SEMARCHY` was added to the core-spec `Dialect` enum for - this purpose. +- **Computed fields / SemQL** (asymmetric by design): + - *Import* does **not** carry a `SemQLEnricher` onto the field. An enricher is + a data-preparation rule; once it runs, the value is materialized in the + column, so a consumer of the model sees a plain column. Enrichers are dropped + with a warning and the field keeps only its `ANSI_SQL` column reference. + - *Export* still supports the reverse: if an OSI model carries a `SEMARCHY` + dialect expression on a field (SemQL is *not* SQL and is never translated), + those fields are grouped back into one `SemQLEnricher` per entity. The entity + attribute's physical column always comes from the `ANSI_SQL` expression, + never the SemQL. `SEMARCHY` was added to the core-spec `Dialect` enum for + this purpose. - **Validation**: OSI output is validated against `schemas/osi-schema.json` (draft 2020-12); each emitted Semarchy object is validated against its per-type schema in `schemas/` (draft-07). Best-effort — skipped with a warning @@ -89,9 +93,8 @@ following are **dropped with a warning**, not preserved in `custom_extensions`: - **Metrics** — OSI metrics have no Semarchy equivalent (no measure concept), so they are dropped on export. -- **SemQL constructs** — `SemQLEnricher` computed attributes *are* mapped (see - Design notes), but the enricher-level `condition`, matchers, and survivorship - rules are dropped. +- **SemQL constructs** — `SemQLEnricher` is import-dropped / export-only (see + Design notes); matchers and survivorship rules are dropped. - **Type definitions** — `ComplexType`, `UserDefinedType`, `LOVType` are dropped. - **Views** — `DatabaseView`, `BusinessView` are dropped. - **UI / process objects** — `Form`, `SearchForm`, `Stepper`, `Workflow`, diff --git a/converters/semarchy/src/semarchy-to-osi.ts b/converters/semarchy/src/semarchy-to-osi.ts index 0dc113d5..6cd1dd1c 100644 --- a/converters/semarchy/src/semarchy-to-osi.ts +++ b/converters/semarchy/src/semarchy-to-osi.ts @@ -20,7 +20,6 @@ import type { } from "./osi-types.js"; import { isTimeDataType } from "./semarchy-types.js"; import type { - SemarchyEnricher, SemarchyEntity, SemarchyModel, SemarchyReference, @@ -114,52 +113,6 @@ function referenceToRelationship( }; } -/** - * Apply a SemQL enricher onto the OSI datasets: each `{attributeName, - * expression}` adds a SEMARCHY dialect entry to the matching field (keeping its - * ANSI_SQL column reference), turning it into a computed field. If no field - * matches, a SEMARCHY-only field is created. The enricher-level `condition` has - * no OSI equivalent and is dropped with a warning. - */ -function applyEnricher( - enricher: SemarchyEnricher, - datasetsByName: Map, - warnings: string[], -): void { - const dataset = datasetsByName.get(localName(enricher.entity)); - if (!dataset) { - warnings.push( - `[drop] enricher "${enricher._name}": entity "${enricher.entity}" not found`, - ); - return; - } - if (enricher.condition) { - warnings.push( - `[drop] enricher "${enricher._name}": SemQL condition has no OSI ` + - `equivalent and is dropped`, - ); - } - dataset.fields ??= []; - for (const { attributeName, expression } of enricher.semQlEnricherExpressions) { - const name = localName(attributeName); - const semarchy = { dialect: "SEMARCHY" as const, expression }; - const field = dataset.fields.find((f) => f.name === name); - if (field) { - // Replace any prior SEMARCHY entry, keep other dialects (e.g. ANSI_SQL). - field.expression.dialects = [ - ...field.expression.dialects.filter((d) => d.dialect !== "SEMARCHY"), - semarchy, - ]; - } else { - warnings.push( - `[warn] enricher "${enricher._name}": attribute "${name}" is not an ` + - `entity attribute; creating a computed-only field`, - ); - dataset.fields.push({ name, expression: { dialects: [semarchy] } }); - } - } -} - /** Convert a grouped Semarchy model into an OSI document. */ export function semarchyToOsi( model: SemarchyModel, @@ -168,9 +121,16 @@ export function semarchyToOsi( const entitiesByName = new Map(model.entities.map((e) => [e._name, e])); const datasets = model.entities.map((e) => entityToDataset(e, model)); - const datasetsByName = new Map(datasets.map((d) => [d.name, d])); + + // SemQL enrichers are a data-preparation rule: in the MDM the computed value + // is already materialized in the column, so a consumer of the model sees a + // plain column. We therefore do NOT turn enrichers into field expressions on + // import; they are dropped with a warning. for (const enricher of model.enrichers) { - applyEnricher(enricher, datasetsByName, warnings); + warnings.push( + `[drop] enricher "${enricher._name}": the computed value is materialized ` + + `in the column; the field stays a plain column reference`, + ); } const relationships = model.references diff --git a/converters/semarchy/tests/roundtrip.test.ts b/converters/semarchy/tests/roundtrip.test.ts index b65af29d..69b4cbca 100644 --- a/converters/semarchy/tests/roundtrip.test.ts +++ b/converters/semarchy/tests/roundtrip.test.ts @@ -73,8 +73,8 @@ describe("import Semarchy -> OSI", () => { }); }); -describe("SemQL enricher <-> computed field", () => { - it("import adds a SEMARCHY dialect to the enriched field, keeping ANSI_SQL", () => { +describe("SemQL enricher handling", () => { + it("import drops enrichers: the enriched field stays a plain column", () => { const warnings: string[] = []; const model = readSemarchyDir(FIXTURE_DIR, warnings); const { document } = semarchyToOsi(model, warnings); @@ -82,33 +82,46 @@ describe("SemQL enricher <-> computed field", () => { const fullName = document.semantic_model[0]!.datasets .find((d) => d.name === "Customer")! .fields!.find((f) => f.name === "fullName")!; - const byDialect = Object.fromEntries( - fullName.expression.dialects.map((d) => [d.dialect, d.expression]), - ); - expect(byDialect.ANSI_SQL).toBe("FULL_NAME"); // physical column preserved - expect(byDialect.SEMARCHY).toBe("UPPER(fullName)"); // SemQL computation - - // The enricher-level condition has no OSI equivalent and is dropped. - expect(warnings.some((w) => w.includes("SemQL condition"))).toBe(true); + // Only the physical column reference — no SEMARCHY computation is attached, + // because in the MDM the value is already materialized in the column. + expect(fullName.expression.dialects).toEqual([ + { dialect: "ANSI_SQL", expression: "FULL_NAME" }, + ]); + expect(warnings.some((w) => w.includes('enricher "CustomerEnricher"'))).toBe(true); }); - it("export regenerates a SemQLEnricher from the computed field", () => { - const model = readSemarchyDir(FIXTURE_DIR); - const { document } = semarchyToOsi(model); - const { model: exported } = osiToSemarchy(document.semantic_model[0]!); - - expect(exported.enrichers).toHaveLength(1); - const enricher = exported.enrichers[0]!; - expect(enricher.entity).toBe("retail.entities.Customer.Customer"); - expect(enricher.semQlEnricherExpressions).toEqual([ + it("export generates a SemQLEnricher from a SEMARCHY-dialect field", () => { + // A hand-authored OSI model carrying a SemQL computation. + const { model } = osiToSemarchy({ + name: "retail", + datasets: [ + { + name: "Customer", + source: "retail.CUSTOMER", + fields: [ + { + name: "fullName", + expression: { + dialects: [ + { dialect: "ANSI_SQL", expression: "FULL_NAME" }, + { dialect: "SEMARCHY", expression: "UPPER(fullName)" }, + ], + }, + }, + ], + }, + ], + }); + + expect(model.enrichers).toHaveLength(1); + expect(model.enrichers[0]!.entity).toBe("retail.entities.Customer.Customer"); + expect(model.enrichers[0]!.semQlEnricherExpressions).toEqual([ { attributeName: "fullName", expression: "UPPER(fullName)" }, ]); - // The enriched attribute keeps its physical column (not the SemQL). - const customer = exported.entities.find((e) => e._name === "Customer")!; - expect(customer.attributes.find((a) => a._name === "fullName")!.physicalName).toBe( - "FULL_NAME", - ); - expect(validateSemarchy(exported).valid).toBe(true); + // The attribute's physical column comes from ANSI_SQL, never the SemQL. + const customer = model.entities.find((e) => e._name === "Customer")!; + expect(customer.attributes[0]!.physicalName).toBe("FULL_NAME"); + expect(validateSemarchy(model).valid).toBe(true); }); });