diff --git a/.github/workflows/service-topology.yml b/.github/workflows/service-topology.yml new file mode 100644 index 00000000..2b4f15b9 --- /dev/null +++ b/.github/workflows/service-topology.yml @@ -0,0 +1,25 @@ +name: Service topology +on: + pull_request: + paths: + - 'applications/service-topology/**' + - '.github/workflows/service-topology.yml' + push: + paths: + - 'applications/service-topology/**' + - '.github/workflows/service-topology.yml' +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: applications/service-topology + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.4.2' + - run: bun install --frozen-lockfile --ignore-scripts + - run: bun run typecheck + - run: bun test tests/graph.test.ts + - run: bun run build diff --git a/applications/service-topology/.env.example b/applications/service-topology/.env.example new file mode 100644 index 00000000..b50d4182 --- /dev/null +++ b/applications/service-topology/.env.example @@ -0,0 +1,7 @@ +# Runtime login only. Cloud requires the downloaded CA file. +DATABASE_URL=postgres://topology_app:YOUR_PASSWORD@YOUR_HOST:5432/postgres +PG_CA_CERT_PATH=.deployment/postgres-ca.pem +# Enable only for a loopback development database without TLS: +# DATABASE_LOCAL=true +PORT=3000 +APP_ORIGIN=http://127.0.0.1:3000 diff --git a/applications/service-topology/.gitignore b/applications/service-topology/.gitignore new file mode 100644 index 00000000..1b5df4e8 --- /dev/null +++ b/applications/service-topology/.gitignore @@ -0,0 +1,7 @@ +.env +.deployment/ +.clickhouse/ +node_modules/ +dist/ +test-results/ +playwright-report/ diff --git a/applications/service-topology/README.md b/applications/service-topology/README.md new file mode 100644 index 00000000..ff9aeb29 --- /dev/null +++ b/applications/service-topology/README.md @@ -0,0 +1,302 @@ +# Service atlas + +Build and save a small service architecture map with React Flow, Zustand, ELK, +Elysia, Eden Treaty, and Drizzle's Bun SQL driver, backed by +[ClickHouse Managed Postgres](https://clickhouse.com/cloud/postgres). + +Add services, connect them, drag them into place, or let ELK arrange the graph. +Every save commits the current document and a revision snapshot together. If two +tabs edit the same revision, one saves and the other gets a visible conflict. + +![A saved storefront service map with six services and directed connections](docs/service-topology.png) + +This is a trusted local demo using synthetic architecture metadata. It listens +on loopback and checks the configured Host and write Origin. There are no user +accounts, network discovery, automatic service execution, or live collaboration. + +## How the pieces fit + +| Library | Actual use | +| ----------------- | ---------------------------------------------------------------------- | +| React Flow | Draggable service cards, connection handles, edges, zoom and selection | +| Zustand | Draft nodes/edges, unsaved state, revision and conflict state | +| ELK.js | Layered automatic layout using the actual graph's nodes and edges | +| Elysia | Typed routes, structural validation and explicit error responses | +| Eden Treaty | Typed reads and saves using a type-only backend import | +| Drizzle + Bun SQL | Conditional updates, JSONB documents and atomic snapshot inserts | + +`service_topology.graphs` stores a header, revision and current JSONB document. +`service_topology.revisions` has a primary key on `(graph_id, revision)` and +stores each committed snapshot. The runtime login can read both tables, update +the current document/revision, and insert snapshots. It cannot update or delete +snapshots or change the schema. + +Maps allow up to 100 services and 200 directed connections. Service names are +1–60 characters, IDs are UUIDs, coordinates are bounded finite numbers, and +connections must reference existing services. Self connections and repeated +connections in the same direction are rejected. Reverse connections are allowed. + +[repository.ts](src/server/repository.ts) updates with both ID and base revision +in its predicate. It inserts the snapshot in the same transaction. A zero-row +update returns `409`. If insertion fails, the current document also rolls back. +Reads return the document and revision from one row. + +## Get the source and tools + +Run application dependencies inside Linux. On macOS, an isolated +[OrbStack machine](https://docs.orbstack.dev/machines/isolated) keeps dependencies +off the host: + +```sh +orbctl create ubuntu:noble topology-dev --isolated --arch arm64 --memory 4G --cpus 2 +orbctl run -m topology-dev -w /home/$USER bash +``` + +Inside Linux: + +```sh +sudo apt-get update +sudo apt-get install -y ca-certificates curl git unzip jq openssl postgresql-client docker.io +sudo systemctl start docker +sudo usermod -aG docker "$USER" +# Reopen your Linux shell so the Docker group applies. +curl -fsSL https://bun.sh/install -o /tmp/bun-install.sh +bash /tmp/bun-install.sh bun-v1.4.2 +export PATH="$HOME/.bun/bin:$HOME/.local/bin:$PATH" +curl -fsSL https://clickhouse.com/cli | sh +git clone https://github.com/ClickHouse/examples.git +cd examples/applications/service-topology +bun install --frozen-lockfile --ignore-scripts +``` + +The checked-in lockfile pins the library versions. Bun must report `1.4.2`. +No third-party application service is required. Cloud lifecycle commands may +instead run with an existing host `clickhousectl`; copy only the database CA and +step-specific credentials into the VM, keeping Cloud API keys on the host. + +## Choose a database + +### Local Postgres with clickhousectl + +```sh +umask 077 +mkdir -p .deployment +clickhousectl local postgres start --name topology --version 18 --json \ + > .deployment/local-create.json +export PGHOST=127.0.0.1 +export PGPORT="$(jq -er '.port' .deployment/local-create.json)" +export PGDATABASE="$(jq -er '.database' .deployment/local-create.json)" +export PGADMIN="$(jq -er '.user' .deployment/local-create.json)" +export PGPASSWORD="$(jq -er '.password' .deployment/local-create.json)" +export PGSSLMODE=disable +``` + +Use the actual returned port. This receipt contains a password and belongs in +the ignored private directory. + +### ClickHouse Managed Postgres with clickhousectl + +Create an Admin API key following the +[Cloud API guide](https://clickhouse.com/docs/cloud/manage/openapi), then enter +it interactively: + +```sh +clickhousectl cloud auth login --interactive +clickhousectl cloud auth status +clickhousectl cloud org list +umask 077 +mkdir -p .deployment +``` + +Create `.deployment/resources.env` in an editor with your organization, an +available AWS region and Postgres size: + +```dotenv +CH_ORG_ID=YOUR_ORGANIZATION_UUID +CLOUD_REGION=YOUR_AVAILABLE_REGION +PG_SIZE=YOUR_AVAILABLE_SIZE +``` + +One non-HA Postgres 18 service is enough for this example. Review +[pricing](https://clickhouse.com/pricing) before creating it. Compute, storage, +backups and network usage can incur charges; stopping the application or VM +does not stop the managed service's billing. + +```sh +source .deployment/resources.env +clickhousectl cloud postgres create --org-id "$CH_ORG_ID" \ + --name service-topology-example --provider aws --region "$CLOUD_REGION" \ + --size "$PG_SIZE" --pg-version 18 --ha-type none \ + --tag project=service-topology --json > .deployment/postgres-create.json +PG_SERVICE_ID="$(jq -er '.id' .deployment/postgres-create.json)" +``` + +Save the service ID in `.deployment/resources.env`. Preserve the create receipt: +its initial password is returned once. If creation is interrupted, inspect +`clickhousectl cloud postgres list --org-id "$CH_ORG_ID" --json` before retrying. + +Repeat inspection until `state` is `running`: + +```sh +clickhousectl cloud postgres get "$PG_SERVICE_ID" --org-id "$CH_ORG_ID" --json \ + > .deployment/postgres-status.json +jq '{id,state,hostname,username,postgresVersion,size}' .deployment/postgres-status.json +clickhousectl cloud postgres certs get "$PG_SERVICE_ID" --org-id "$CH_ORG_ID" \ + --output .deployment/postgres-ca.pem +``` + +Use the service's actual direct hostname, port, database and administrator. +For a direct service connection, use port 5432 and database `postgres`; check the service response for the actual connection details: + +```sh +export PGHOST="$(jq -er '.hostname' .deployment/postgres-status.json)" +export PGPORT=5432 PGDATABASE=postgres +export PGADMIN="$(jq -er '.username' .deployment/postgres-status.json)" +export PGPASSWORD="$(jq -er '.password' .deployment/postgres-create.json)" +export PGSSLMODE=verify-full +export PGSSLROOTCERT="$PWD/.deployment/postgres-ca.pem" +``` + +## Apply roles, schema and seed + +Run once on your dedicated fresh database. Passwords are read by psql from +environment variables and do not become command arguments. + +```sh +cat > .deployment/passwords.env < .env <=1.4.19" } }, "sha512-3CKVD4ycVjB8nCNssfmhnUuq3SzSHkUES3v5PNCFr9LxIrx39/HVRAZ8z2sLxrFqzUs48dCBZaxoZzJ5UUVHDA=="], + + "@oxc-project/types": ["@oxc-project/types@0.152.0", "", {}, "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw=="], + + "@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="], + + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.12", "", { "os": "android", "cpu": "arm" }, "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.12", "", { "os": "android", "cpu": "arm64" }, "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.12", "", { "os": "linux", "cpu": "arm" }, "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.34.52", "", {}, "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw=="], + + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.12", "", {}, "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw=="], + + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.9", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA=="], + + "@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="], + + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], + + "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="], + + "@xyflow/react": ["@xyflow/react@12.12.0", "", { "dependencies": { "@xyflow/system": "0.0.83", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "@types/react": ">=17", "@types/react-dom": ">=17", "react": ">=17", "react-dom": ">=17" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-74oXI0Rgm1Eu33cHQAd74J6oVgCH9mbFyTVQbsGgp2kmWwh0HdtBr2VP04z8mbPD+M/xO69nqG5dDEt7/EN2rQ=="], + + "@xyflow/system": ["@xyflow/system@0.0.83", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-a//eDjez3WZF47aaPTfVUDD0OINOq+WVwBZCRTqG+m0a5euX8EwlJ4jMY1Wxz0Ocj4xOeekJ70V22Pq0ll8RJw=="], + + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + + "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "drizzle-orm": ["drizzle-orm@0.45.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@netlify/db": ">=0.4.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@netlify/db", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA=="], + + "elkjs": ["elkjs@0.12.0", "", {}, "sha512-YZcKynxVxYoKIOEpywEPwCFdg+BTbxQRNf3pbwdDCvc8O3kQD8bmIwSxKU1eOTVc4Xo+VG9Te+575mlfvOrhEQ=="], + + "elysia": ["elysia@1.4.30", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-S2qqV0CbM4faB1hQQ5IYoeYnAUinjHlzRduxaBc4OoNqh0GjOc8k6tt3hv5ozWcG6Svr6hugw6JL4zNke4jwfA=="], + + "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], + + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-type": ["file-type@22.1.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-sEB0oStmeLGtpmSAD88RDNuLsvhv4iC3u0lE25CUvfCULvj49xoQ3l3K525Vn+ZzsZ6DOLajQCV6RmhwwhqiJg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], + + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], + + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + + "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + + "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], + + "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], + + "rolldown": ["rolldown@1.2.12", "", { "dependencies": { "@oxc-project/types": "=0.152.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.12", "@rolldown/binding-android-arm64": "1.2.12", "@rolldown/binding-darwin-arm64": "1.2.12", "@rolldown/binding-darwin-x64": "1.2.12", "@rolldown/binding-freebsd-x64": "1.2.12", "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", "@rolldown/binding-linux-arm64-gnu": "1.2.12", "@rolldown/binding-linux-arm64-musl": "1.2.12", "@rolldown/binding-linux-ppc64-gnu": "1.2.12", "@rolldown/binding-linux-s390x-gnu": "1.2.12", "@rolldown/binding-linux-x64-gnu": "1.2.12", "@rolldown/binding-linux-x64-musl": "1.2.12", "@rolldown/binding-openharmony-arm64": "1.2.12", "@rolldown/binding-win32-arm64-msvc": "1.2.12", "@rolldown/binding-win32-x64-msvc": "1.2.12" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ=="], + + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + + "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "uint8array-extras": ["uint8array-extras@1.6.0", "", {}, "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="], + + "vite": ["vite@8.3.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.11", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w=="], + + "zustand": ["zustand@5.0.15", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A=="], + + "@xyflow/react/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + } +} diff --git a/applications/service-topology/docs/service-topology.png b/applications/service-topology/docs/service-topology.png new file mode 100644 index 00000000..a01e836e Binary files /dev/null and b/applications/service-topology/docs/service-topology.png differ diff --git a/applications/service-topology/index.html b/applications/service-topology/index.html new file mode 100644 index 00000000..2552bdf6 --- /dev/null +++ b/applications/service-topology/index.html @@ -0,0 +1 @@ +Service atlas
diff --git a/applications/service-topology/package.json b/applications/service-topology/package.json new file mode 100644 index 00000000..34469e84 --- /dev/null +++ b/applications/service-topology/package.json @@ -0,0 +1,35 @@ +{ + "name": "service-topology", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --watch src/server/index.ts", + "dev:web": "vite --host 127.0.0.1", + "build": "vite build", + "start": "bun src/server/index.ts", + "typecheck": "tsc --noEmit", + "test": "bun test tests/graph.test.ts", + "test:integration": "bun test tests/database.test.ts", + "test:browser": "playwright test" + }, + "dependencies": { + "@elysiajs/eden": "1.4.9", + "@sinclair/typebox": "0.34.52", + "@xyflow/react": "12.12.0", + "drizzle-orm": "0.45.3", + "elysia": "1.4.30", + "elkjs": "0.12.0", + "react": "19.3.0", + "react-dom": "19.3.0", + "zustand": "5.0.15" + }, + "devDependencies": { + "@playwright/test": "1.63.0", + "@types/bun": "1.4.2", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "6.1.1", + "typescript": "7.0.2", + "vite": "8.3.2" + } +} diff --git a/applications/service-topology/playwright.config.ts b/applications/service-topology/playwright.config.ts new file mode 100644 index 00000000..18552ef1 --- /dev/null +++ b/applications/service-topology/playwright.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "@playwright/test"; +export default defineConfig({ + testDir: "./tests/browser", + use: { + baseURL: process.env.BROWSER_BASE_URL || "http://127.0.0.1:3000", + viewport: { width: 1440, height: 1000 }, + }, + workers: 1, + reporter: "list", +}); diff --git a/applications/service-topology/sql/001_roles.sql b/applications/service-topology/sql/001_roles.sql new file mode 100644 index 00000000..7aa487c6 --- /dev/null +++ b/applications/service-topology/sql/001_roles.sql @@ -0,0 +1,10 @@ +\set ON_ERROR_STOP on +\getenv migration_password PG_MIGRATION_PASSWORD +\getenv app_password PG_APP_PASSWORD +CREATE ROLE topology_owner NOLOGIN; +CREATE ROLE topology_migration LOGIN PASSWORD :'migration_password'; +CREATE ROLE topology_app LOGIN PASSWORD :'app_password'; +GRANT topology_owner TO topology_migration; +CREATE SCHEMA service_topology AUTHORIZATION topology_owner; +REVOKE ALL ON SCHEMA service_topology FROM PUBLIC; +GRANT USAGE ON SCHEMA service_topology TO topology_app; diff --git a/applications/service-topology/sql/002_schema.sql b/applications/service-topology/sql/002_schema.sql new file mode 100644 index 00000000..c1da665b --- /dev/null +++ b/applications/service-topology/sql/002_schema.sql @@ -0,0 +1,28 @@ +\set ON_ERROR_STOP on +BEGIN; +SET LOCAL ROLE topology_owner; +CREATE TABLE service_topology.graphs ( + id UUID PRIMARY KEY, + title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 80), + revision INTEGER NOT NULL CHECK (revision > 0), + document JSONB NOT NULL CHECK ( + document ? 'nodes' AND document ? 'edges' AND + jsonb_typeof(document) = 'object' AND + jsonb_typeof(document->'nodes') = 'array' AND + jsonb_typeof(document->'edges') = 'array' AND + jsonb_array_length(document->'nodes') <= 100 AND + jsonb_array_length(document->'edges') <= 200 + ), + updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() +); +CREATE TABLE service_topology.revisions ( + graph_id UUID NOT NULL REFERENCES service_topology.graphs(id) ON DELETE CASCADE, + revision INTEGER NOT NULL CHECK (revision > 0), + document JSONB NOT NULL, + saved_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (graph_id, revision) +); +GRANT SELECT ON service_topology.graphs, service_topology.revisions TO topology_app; +GRANT UPDATE (revision, document, updated_at) ON service_topology.graphs TO topology_app; +GRANT INSERT ON service_topology.revisions TO topology_app; +COMMIT; diff --git a/applications/service-topology/sql/003_seed.sql b/applications/service-topology/sql/003_seed.sql new file mode 100644 index 00000000..b56cf032 --- /dev/null +++ b/applications/service-topology/sql/003_seed.sql @@ -0,0 +1,24 @@ +\set ON_ERROR_STOP on +BEGIN; +SET LOCAL ROLE topology_owner; +INSERT INTO service_topology.graphs (id, title, revision, document) VALUES +('67bc2cf0-5b0f-45c8-bf6a-7b4bb5155a50', 'Storefront architecture', 1, '{ + "nodes": [ + {"id":"11111111-1111-4111-8111-111111111111","type":"service","position":{"x":80,"y":100},"data":{"label":"Storefront","kind":"web"}}, + {"id":"22222222-2222-4222-8222-222222222222","type":"service","position":{"x":350,"y":100},"data":{"label":"Checkout API","kind":"api"}}, + {"id":"33333333-3333-4333-8333-333333333333","type":"service","position":{"x":620,"y":20},"data":{"label":"Orders Postgres","kind":"database"}}, + {"id":"44444444-4444-4444-8444-444444444444","type":"service","position":{"x":620,"y":180},"data":{"label":"Fulfilment worker","kind":"worker"}}, + {"id":"55555555-5555-4555-8555-555555555555","type":"service","position":{"x":890,"y":180},"data":{"label":"Warehouse API","kind":"api"}} + ], + "edges": [ + {"id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","source":"11111111-1111-4111-8111-111111111111","target":"22222222-2222-4222-8222-222222222222"}, + {"id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","source":"22222222-2222-4222-8222-222222222222","target":"33333333-3333-4333-8333-333333333333"}, + {"id":"cccccccc-cccc-4ccc-8ccc-cccccccccccc","source":"22222222-2222-4222-8222-222222222222","target":"44444444-4444-4444-8444-444444444444"}, + {"id":"dddddddd-dddd-4ddd-8ddd-dddddddddddd","source":"44444444-4444-4444-8444-444444444444","target":"55555555-5555-4555-8555-555555555555"} + ] +}'::jsonb) ON CONFLICT (id) DO NOTHING; +INSERT INTO service_topology.revisions (graph_id, revision, document) +SELECT id, revision, document FROM service_topology.graphs +WHERE id = '67bc2cf0-5b0f-45c8-bf6a-7b4bb5155a50' +ON CONFLICT DO NOTHING; +COMMIT; diff --git a/applications/service-topology/src/client/api.ts b/applications/service-topology/src/client/api.ts new file mode 100644 index 00000000..41ed3323 --- /dev/null +++ b/applications/service-topology/src/client/api.ts @@ -0,0 +1,3 @@ +import { treaty } from "@elysiajs/eden"; +import type { App } from "../server/app"; +export const api = treaty(window.location.origin); diff --git a/applications/service-topology/src/client/main.tsx b/applications/service-topology/src/client/main.tsx new file mode 100644 index 00000000..8b0e5826 --- /dev/null +++ b/applications/service-topology/src/client/main.tsx @@ -0,0 +1,366 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { + ReactFlow, + Background, + Controls, + Handle, + Position, + MarkerType, + type NodeProps, + type Node, + type ReactFlowInstance, +} from "@xyflow/react"; +import { api } from "./api"; +import { useGraph, documentFromState } from "./store"; +import { + DEMO_GRAPH_ID, + type ServiceKind, + type ServiceNode, + type ServiceEdge, +} from "../shared/types"; +import "@xyflow/react/dist/style.css"; +import "./style.css"; + +const kinds: ServiceKind[] = ["web", "api", "worker", "database"]; +function ServiceCard({ + id, + data, + selected, +}: NodeProps>) { + return ( +
+ +
{data.kind}
+ {data.label || "Untitled service"} + {id.slice(0, 8)} + +
+ ); +} +const nodeTypes = { service: ServiceCard }; + +function App() { + const state = useGraph(); + const [selected, select] = React.useState(); + const [kind, setKind] = React.useState("api"); + const [flow, setFlow] = + React.useState>(); + const node = state.nodes.find((n) => n.id === selected); + async function reload() { + state.begin(); + const generation = useGraph.getState().generation; + try { + const { data, error } = await api.api.graphs({ id: DEMO_GRAPH_ID }).get(); + if (error || !data || !("document" in data)) + throw new Error("Could not load the map."); + if (useGraph.getState().generation !== generation) + return state.fail( + "The draft changed while loading. Reload again when ready.", + ); + useGraph.getState().load(data); + select(undefined); + } catch { + state.fail( + "Could not load the map. Check the server and database connection.", + ); + } finally { + useGraph.setState({ busy: false }); + } + } + React.useEffect(() => { + void reload(); + }, []); + async function save() { + const { saved, generation } = useGraph.getState(); + if (!saved) return; + const document = documentFromState(); + state.begin(); + try { + const { data, error } = await api.api + .graphs({ id: saved.id }) + .put({ baseRevision: saved.revision, document }); + if (error) { + const message = + typeof error.value === "object" && + error.value && + "message" in error.value + ? String(error.value.message) + : "Save failed."; + return state.fail(message, error.status === 409); + } + if (data && "document" in data) state.finish(data, generation); + } catch { + state.fail("Could not reach the server. Your draft is still here."); + } + } + async function layout() { + state.begin(); + const startGeneration = useGraph.getState().generation; + try { + const { default: ELK } = await import("elkjs/lib/elk.bundled.js"); + const elk = new ELK(); + const graph = await elk.layout({ + id: "root", + layoutOptions: { + "elk.algorithm": "layered", + "elk.direction": "RIGHT", + "elk.spacing.nodeNode": "70", + "elk.layered.spacing.nodeNodeBetweenLayers": "100", + }, + children: state.nodes.map((n) => ({ + id: n.id, + width: 200, + height: 104, + })), + edges: state.edges.map((e) => ({ + id: e.id, + sources: [e.source], + targets: [e.target], + })), + }); + if (useGraph.getState().generation !== startGeneration) + return state.fail("The map changed during layout. Run layout again."); + state.layout( + new Map( + (graph.children ?? []).map((n) => [ + n.id, + { x: n.x ?? 0, y: n.y ?? 0 }, + ]), + ), + ); + requestAnimationFrame(() => { + void flow?.fitView({ padding: 0.25 }); + }); + } catch { + state.fail( + "Automatic layout could not finish. You can still drag services.", + ); + } finally { + useGraph.setState({ busy: false }); + } + } + const status = state.conflict + ? "Save conflict" + : state.dirty + ? "Unsaved changes" + : "Saved"; + return ( +
+
+
+ ↗ +
+ Service atlas + Architecture, kept in view +
+
+
+ + Trusted local demo +
+
+
+
+
+
YOUR SERVICE MAP
+

{state.saved?.title ?? "Storefront architecture"}

+

+ Map the services behind a request. Save a revision your team can + return to. +

+
+
+ COMMITTED REVISION + + {state.saved + ? String(state.saved.revision).padStart(2, "0") + : "—"} + +
+
+
+
+ + +
+
+ + + {status} + + +
+
+ {state.message && ( +
+ {state.message} + {state.conflict && ( + + )} +
+ )} +
+
+ + nodes={state.nodes} + edges={state.edges} + nodeTypes={nodeTypes} + onInit={setFlow} + onNodesChange={state.nodeChanges} + onEdgesChange={state.edgeChanges} + onConnect={state.connect} + onNodeClick={(_, n) => select(n.id)} + onPaneClick={() => select(undefined)} + fitView + minZoom={0.2} + maxZoom={2} + defaultEdgeOptions={{ + type: "smoothstep", + markerEnd: { type: MarkerType.ArrowClosed, color: "#758897" }, + style: { stroke: "#758897", strokeWidth: 2 }, + }} + deleteKeyCode={["Backspace", "Delete"]} + > + + + +
+ Drag to arrange · Connect right handle to left handle +
+
+ +
+
+
+ React Flow · Zustand · ELK · Elysia + Eden · Drizzle + ClickHouse Managed Postgres +
+
+ ); +} +createRoot(document.getElementById("root")!).render(); diff --git a/applications/service-topology/src/client/store.ts b/applications/service-topology/src/client/store.ts new file mode 100644 index 00000000..d9bf8082 --- /dev/null +++ b/applications/service-topology/src/client/store.ts @@ -0,0 +1,161 @@ +import { create } from "zustand"; +import { + applyEdgeChanges, + applyNodeChanges, + type EdgeChange, + type NodeChange, + type Connection, +} from "@xyflow/react"; +import type { + GraphDocument, + SavedGraph, + ServiceNode, + ServiceEdge, + ServiceKind, +} from "../shared/types"; +type State = { + saved?: SavedGraph; + nodes: ServiceNode[]; + edges: ServiceEdge[]; + dirty: boolean; + busy: boolean; + message: string; + conflict: boolean; + generation: number; + load: (graph: SavedGraph) => void; + nodeChanges: (changes: NodeChange[]) => void; + edgeChanges: (changes: EdgeChange[]) => void; + connect: (connection: Connection) => void; + add: (kind: ServiceKind) => void; + rename: (id: string, label: string) => void; + remove: (id: string) => void; + layout: (positions: Map) => void; + begin: () => void; + finish: (graph: SavedGraph, generation: number) => void; + fail: (message: string, conflict?: boolean) => void; +}; +export const useGraph = create((set, get) => ({ + nodes: [], + edges: [], + dirty: false, + busy: false, + message: "", + conflict: false, + generation: 0, + load: (graph) => + set({ + saved: graph, + nodes: graph.document.nodes, + edges: graph.document.edges, + dirty: false, + conflict: false, + message: "", + generation: get().generation + 1, + }), + nodeChanges: (changes) => { + const contentChange = changes.some( + (c) => c.type !== "select" && c.type !== "dimensions", + ); + const nodes = applyNodeChanges(changes, get().nodes); + const ids = new Set(nodes.map((n) => n.id)); + set({ + nodes, + edges: get().edges.filter((e) => ids.has(e.source) && ids.has(e.target)), + ...(contentChange + ? { dirty: true, generation: get().generation + 1 } + : {}), + }); + }, + edgeChanges: (changes) => + set({ + edges: applyEdgeChanges(changes, get().edges), + ...(changes.some((c) => c.type !== "select") + ? { dirty: true, generation: get().generation + 1 } + : {}), + }), + connect: (c) => { + if (!c.source || !c.target) return; + if (c.source === c.target) + return set({ message: "A service cannot connect to itself." }); + if (get().edges.some((e) => e.source === c.source && e.target === c.target)) + return set({ message: "That connection already exists." }); + if (get().edges.length >= 200) + return set({ message: "This map allows up to 200 connections." }); + set({ + edges: [ + ...get().edges, + { id: crypto.randomUUID(), source: c.source, target: c.target }, + ], + dirty: true, + generation: get().generation + 1, + message: "", + }); + }, + add: (kind) => { + if (get().nodes.length >= 100) + return set({ message: "This map allows up to 100 services." }); + set({ + nodes: [ + ...get().nodes, + { + id: crypto.randomUUID(), + type: "service", + position: { x: 120 + get().nodes.length * 30, y: 320 }, + data: { label: "New " + kind, kind }, + }, + ], + dirty: true, + generation: get().generation + 1, + }); + }, + rename: (id, label) => + set({ + nodes: get().nodes.map((n) => + n.id === id ? { ...n, data: { ...n.data, label } } : n, + ), + dirty: true, + generation: get().generation + 1, + }), + remove: (id) => + set({ + nodes: get().nodes.filter((n) => n.id !== id), + edges: get().edges.filter((e) => e.source !== id && e.target !== id), + dirty: true, + generation: get().generation + 1, + }), + layout: (positions) => + set({ + nodes: get().nodes.map((n) => ({ + ...n, + position: positions.get(n.id) ?? n.position, + })), + dirty: true, + generation: get().generation + 1, + }), + begin: () => set({ busy: true, message: "" }), + finish: (graph, generation) => + set({ + saved: graph, + busy: false, + conflict: false, + dirty: get().generation !== generation, + message: + get().generation === generation + ? "All changes saved." + : "Snapshot saved. Your newer edits are still unsaved.", + }), + fail: (message, conflict = false) => set({ message, conflict, busy: false }), +})); +export function documentFromState(): GraphDocument { + const { nodes, edges } = useGraph.getState(); + // React Flow keeps selection and measured dimensions locally; persist only the document. + return { + nodes: nodes.map(({ id, type, position, data }) => ({ + id, + type, + position: { ...position }, + data: { ...data }, + })), + edges: edges.map(({ id, source, target }) => ({ id, source, target })), + }; +} diff --git a/applications/service-topology/src/client/style.css b/applications/service-topology/src/client/style.css new file mode 100644 index 00000000..fd98ca72 --- /dev/null +++ b/applications/service-topology/src/client/style.css @@ -0,0 +1,392 @@ +:root { + font-family: system-ui, sans-serif; + color: #233a3c; + background: #f4f6f2; + font-synthesis: none; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +button, +input, +select { + font: inherit; +} +button, +select { + border: 1px solid #cad4d0; + border-radius: 7px; + background: #fff; + color: #284548; + padding: 10px 14px; +} +button { + cursor: pointer; + font-weight: 600; +} +button:hover { + background: #edf3ec; +} +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.primary { + background: #294e46; + color: #fff; + border-color: #294e46; +} +.primary:hover { + background: #37665b; +} +.shell { + min-height: 100vh; +} +header { + height: 82px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #dde3db; + padding: 0 42px; + background: #fbfcf8; +} +.brand { + display: flex; + gap: 13px; + align-items: center; +} +.brand-mark { + background: #d4e7b6; + display: grid; + place-items: center; + width: 39px; + height: 39px; + border-radius: 10px; + font-size: 29px; +} +.brand strong { + font-family: "Trebuchet MS", sans-serif; + font-size: 20px; + display: block; +} +.brand small { + font-size: 12px; + color: #75857e; +} +.header-meta { + font-size: 12px; + display: flex; + gap: 8px; + align-items: center; +} +.local-dot { + height: 7px; + width: 7px; + border-radius: 50%; + background: #699963; +} +main { + max-width: 1500px; + margin: auto; + padding: 35px 42px 18px; +} +.intro { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 28px; +} +.eyebrow { + font-size: 10px; + letter-spacing: 2px; + font-weight: 700; + color: #73826f; +} +h1 { + font-family: "Trebuchet MS", sans-serif; + font-weight: 500; + font-size: 32px; + letter-spacing: -1px; + margin: 10px 0; +} +p { + color: #74827e; + line-height: 1.65; + font-size: 13px; + margin: 8px 0; +} +.intro p { + font-size: 14px; +} +.revision { + display: flex; + gap: 20px; + align-items: center; + background: #e9eee1; + padding: 15px 20px; + border-radius: 8px; +} +.revision span { + width: 85px; + line-height: 1.7; + font-size: 9px; + letter-spacing: 1px; + color: #6e7e66; +} +.revision strong { + font-family: "Trebuchet MS", sans-serif; + font-size: 35px; + font-weight: 500; +} +.toolbar { + display: flex; + justify-content: space-between; + gap: 10px; + background: #fbfcf8; + padding: 14px 16px; + border: 1px solid #d8e1d7; + border-radius: 12px 12px 0 0; +} +.add-tools, +.actions { + display: flex; + align-items: center; + gap: 10px; +} +.save-state { + font-size: 11px; + padding: 0 8px; + color: #638165; +} +.save-state.dirty { + color: #ac792d; +} +.workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 275px; + border: 1px solid #d8e1d7; + border-top: 0; + border-radius: 0 0 12px 12px; + overflow: hidden; +} +.canvas { + height: 590px; + position: relative; + background: #f9fbf6; +} +.canvas-caption { + position: absolute; + bottom: 18px; + left: 60px; + font-size: 10px; + color: #889690; + pointer-events: none; + background: #f9fbf6cc; + padding: 4px 8px; +} +aside { + background: #fbfcf8; + border-left: 1px solid #d8e1d7; + padding: 25px 22px; +} +h2 { + font-family: "Trebuchet MS", sans-serif; + font-size: 20px; + font-weight: 500; + line-height: 1.4; + margin: 14px 0; +} +label { + display: block; + font-size: 12px; + margin: 20px 0 7px; +} +input { + width: 100%; + padding: 10px; + border: 1px solid #c9d3ce; + border-radius: 6px; + background: white; +} +.service-card { + width: 200px; + min-height: 104px; + padding: 17px 19px; + border-radius: 9px; + border: 1px solid #cad6c5; + border-top: 4px solid #91ad73; + box-shadow: 0 3px 10px #233a3c07; + background: white; +} +.service-card.api { + border-top-color: #6c9da9; +} +.service-card.worker { + border-top-color: #bda273; +} +.service-card.database { + border-top-color: #a193b6; +} +.service-card.selected { + outline: 2px solid #345b4d; + outline-offset: 3px; +} +.service-kind { + text-transform: uppercase; + font-size: 9px; + letter-spacing: 1.5px; + color: #7a8c81; +} +.service-card strong { + font-size: 13px; + margin: 8px 0; + display: block; +} +.service-ref { + font-size: 9px; + color: #a0aea8; +} +.react-flow__handle { + background: #6b9180; + width: 9px; + height: 9px; + border: 2px solid white; +} +.counts { + display: flex; + gap: 30px; + border-top: 1px solid #e1e7dd; + border-bottom: 1px solid #e1e7dd; + padding: 20px 0; + margin: 20px 0; +} +.counts strong { + display: block; + font-family: "Trebuchet MS", sans-serif; + font-size: 28px; + font-weight: 500; +} +.counts span { + font-size: 11px; + color: #8b958e; +} +.legend { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + font-size: 11px; + margin: 22px 0; +} +.legend div { + display: flex; + align-items: center; + gap: 7px; +} +.swatch { + width: 8px; + height: 8px; + border-radius: 2px; + background: #91ad73; +} +.swatch.api { + background: #6c9da9; +} +.swatch.worker { + background: #bda273; +} +.swatch.database { + background: #a193b6; +} +.storage-note { + border-top: 1px solid #e1e7dd; + padding-top: 20px; + margin-top: 30px; +} +.storage-note strong { + font-size: 12px; +} +.reload { + width: 100%; + margin-top: 18px; + background: transparent; + font-size: 11px; +} +.detail-kind { + font-size: 11px; + color: #73826f; + padding: 12px 0; +} +.danger { + color: #9c564d; + margin: 10px 0; + font-size: 12px; +} +.notice { + padding: 12px 16px; + font-size: 13px; + background: #e9efe1; + border-left: 3px solid #91ad73; + display: flex; + justify-content: space-between; + align-items: center; +} +.notice.conflict { + background: #fff3df; + border-color: #bda273; +} +.notice button { + font-size: 12px; +} +footer { + max-width: 1500px; + margin: auto; + padding: 18px 42px 30px; + display: flex; + justify-content: space-between; + color: #8e9a92; + font-size: 10px; +} +@media (max-width: 900px) { + main { + padding: 24px 16px; + } + header { + padding: 0 16px; + } + .workspace { + grid-template-columns: 1fr; + } + aside { + border-left: 0; + border-top: 1px solid #d8e1d7; + } + .toolbar { + flex-wrap: wrap; + } + .intro { + gap: 10px; + } + h1 { + font-size: 25px; + } + .revision { + padding: 10px; + } + .canvas { + height: 450px; + } + footer { + padding: 20px 16px; + gap: 15px; + } + .header-meta { + display: none; + } + .actions { + flex-wrap: wrap; + } +} diff --git a/applications/service-topology/src/server/app.ts b/applications/service-topology/src/server/app.ts new file mode 100644 index 00000000..b36be7d6 --- /dev/null +++ b/applications/service-topology/src/server/app.ts @@ -0,0 +1,55 @@ +import { Elysia, t } from "elysia"; +import type { Database } from "./database"; +import { graphProblem, saveSchema } from "./graph"; +import { readGraph, saveGraph, StaleRevision } from "./repository"; +import { allowedRequest } from "./origin"; +export const createApi = (db: Database) => + new Elysia({ prefix: "/api" }) + .onRequest(({ request, status }) => { + if (!allowedRequest(request)) + return status(403, { message: "Use the configured local app origin." }); + }) + .get( + "/graphs/:id", + async ({ params, status }) => { + const graph = await readGraph(db, params.id); + return graph ?? status(404, { message: "Map not found." }); + }, + { params: t.Object({ id: t.String({ format: "uuid" }) }) }, + ) + .put( + "/graphs/:id", + async ({ params, body, status }) => { + const problem = graphProblem(body.document); + if (problem) return status(400, { message: problem }); + try { + return await saveGraph( + db, + params.id, + body.baseRevision, + body.document, + ); + } catch (error) { + if (error instanceof StaleRevision) + return status(409, { message: error.message }); + console.error("Graph save failed."); + return status(500, { + message: "Save failed. Your draft is still available.", + }); + } + }, + { + params: t.Object({ id: t.String({ format: "uuid" }) }), + body: saveSchema, + }, + ) + .onError(({ code, status }) => { + if (code === "VALIDATION") + return status(422, { + message: + "The graph format is invalid. Check names, coordinates and size limits.", + }); + console.error("API request failed."); + return status(500, { message: "The map could not be loaded." }); + }); +export type App = ReturnType; diff --git a/applications/service-topology/src/server/database.ts b/applications/service-topology/src/server/database.ts new file mode 100644 index 00000000..ea2bba38 --- /dev/null +++ b/applications/service-topology/src/server/database.ts @@ -0,0 +1,89 @@ +import { SQL } from "bun"; +import { drizzle } from "drizzle-orm/bun-sql"; +export async function connectDatabase( + urlText = process.env.DATABASE_URL, + caPath = process.env.PG_CA_CERT_PATH, +) { + if (!urlText) throw new Error("DATABASE_URL is required."); + const url = new URL(urlText); + if ( + !["postgres:", "postgresql:"].includes(url.protocol) || + url.search || + url.hash + ) + throw new Error("Use a plain Postgres URL without query parameters."); + const local = process.env.DATABASE_LOCAL === "true"; + if (local && !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) + throw new Error("DATABASE_LOCAL requires a loopback host."); + if (!local && !caPath) + throw new Error("PG_CA_CERT_PATH is required for verified TLS."); + url.searchParams.set("sslmode", local ? "disable" : "verify-full"); + const ca = local ? undefined : await Bun.file(caPath!).text(); + const certificates = + ca?.match( + /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g, + ) ?? []; + if (!local && (certificates.length === 0 || certificates.length > 16)) + throw new Error( + "PG_CA_CERT_PATH must contain between 1 and 16 PEM certificates.", + ); + async function connect(trustedCa: string | undefined) { + const client = new SQL(url, { + max: 5, + connectionTimeout: 10, + idleTimeout: 30, + maxLifetime: 1800, + ...(local + ? {} + : { + tls: { + ca: trustedCa, + serverName: url.hostname, + rejectUnauthorized: true, + }, + }), + }); + try { + await client`SELECT 1`; + return { client, db: drizzle({ client }) }; + } catch (error) { + await client.close({ timeout: 0 }); + throw error; + } + } + try { + return await connect(ca); + } catch (originalError) { + if ( + errorCode(originalError) !== "CERT_SIGNATURE_FAILURE" || + certificates.length < 2 + ) + throw originalError; + // Bun can reject rotation bundles containing roots with the same subject. + // Retry only supplied anchors; certificate and hostname verification stay enabled. + for (const certificate of certificates) { + try { + return await connect(certificate); + } catch (error) { + if (!CA_ERRORS.has(errorCode(error))) throw error; + } + } + throw originalError; + } +} +function errorCode(error: unknown): string { + return error !== null && typeof error === "object" && "code" in error + ? String(error.code) + : ""; +} +const CA_ERRORS = new Set([ + "CERT_SIGNATURE_FAILURE", + "UNABLE_TO_GET_ISSUER_CERT", + "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + "SELF_SIGNED_CERT_IN_CHAIN", + "DEPTH_ZERO_SELF_SIGNED_CERT", + "CERT_HAS_EXPIRED", + "CERT_NOT_YET_VALID", +]); +export type Database = Awaited>["db"]; diff --git a/applications/service-topology/src/server/graph.ts b/applications/service-topology/src/server/graph.ts new file mode 100644 index 00000000..e4155bda --- /dev/null +++ b/applications/service-topology/src/server/graph.ts @@ -0,0 +1,62 @@ +import { t } from "elysia"; +import type { GraphDocument } from "../shared/types"; +export const graphSchema = t.Object({ + nodes: t.Array( + t.Object({ + id: t.String({ format: "uuid" }), + type: t.Literal("service"), + position: t.Object({ + x: t.Number({ minimum: -100000, maximum: 100000 }), + y: t.Number({ minimum: -100000, maximum: 100000 }), + }), + data: t.Object({ + label: t.String({ minLength: 1, maxLength: 60 }), + kind: t.Union([ + t.Literal("web"), + t.Literal("api"), + t.Literal("worker"), + t.Literal("database"), + ]), + }), + }), + { maxItems: 100 }, + ), + edges: t.Array( + t.Object({ + id: t.String({ format: "uuid" }), + source: t.String({ format: "uuid" }), + target: t.String({ format: "uuid" }), + }), + { maxItems: 200 }, + ), +}); +export const saveSchema = t.Object({ + baseRevision: t.Integer({ minimum: 1, maximum: 2147483646 }), + document: graphSchema, +}); +export function graphProblem(document: GraphDocument): string | undefined { + const ids = new Set(document.nodes.map((n) => n.id)); + if (ids.size !== document.nodes.length) return "Service IDs must be unique."; + if (document.nodes.some((n) => !n.data.label.trim())) + return "Service names cannot be blank."; + if ( + document.nodes.some( + (n) => !Number.isFinite(n.position.x) || !Number.isFinite(n.position.y), + ) + ) + return "Coordinates must be finite."; + const edges = new Set(); + const pairs = new Set(); + for (const edge of document.edges) { + if (edges.has(edge.id)) return "Connection IDs must be unique."; + if (!ids.has(edge.source) || !ids.has(edge.target)) + return "Connections must reference existing services."; + if (edge.source === edge.target) + return "A service cannot connect to itself."; + const pair = edge.source + ":" + edge.target; + if (pairs.has(pair)) + return "Only one connection in each direction is allowed."; + pairs.add(pair); + edges.add(edge.id); + } +} diff --git a/applications/service-topology/src/server/index.ts b/applications/service-topology/src/server/index.ts new file mode 100644 index 00000000..9e339cbb --- /dev/null +++ b/applications/service-topology/src/server/index.ts @@ -0,0 +1,24 @@ +import { Elysia } from "elysia"; +import { connectDatabase } from "./database"; +import { createApi } from "./app"; +import { allowedRequest } from "./origin"; +const { db, client } = await connectDatabase(); +const app = new Elysia() + .onRequest(({ request, status }) => { + if (!allowedRequest(request)) return status(403); + }) + .use(createApi(db)) + .get("/", () => Bun.file("dist/index.html")) + .get("/assets/:file", ({ params, status }) => + /^[a-zA-Z0-9_.-]+$/.test(params.file) + ? Bun.file("dist/assets/" + params.file) + : status(404), + ) + .listen({ port: Number(process.env.PORT || 3000), hostname: "127.0.0.1" }); +console.log("Service atlas listening on http://127.0.0.1:" + app.server?.port); +for (const signal of ["SIGTERM", "SIGINT"] as const) + process.on(signal, async () => { + await app.stop(); + await client.close(); + process.exit(0); + }); diff --git a/applications/service-topology/src/server/origin.ts b/applications/service-topology/src/server/origin.ts new file mode 100644 index 00000000..8990fdde --- /dev/null +++ b/applications/service-topology/src/server/origin.ts @@ -0,0 +1,18 @@ +export function allowedRequest( + request: Request, + appOrigin = process.env.APP_ORIGIN || "http://127.0.0.1:3000", +) { + const origin = new URL(appOrigin); + if ( + origin.protocol !== "http:" || + origin.origin !== appOrigin || + !["127.0.0.1", "localhost", "[::1]"].includes(origin.hostname) + ) + throw new Error( + "APP_ORIGIN must be a loopback HTTP origin without a trailing slash.", + ); + if (new URL(request.url).host !== origin.host) return false; + return ( + request.method !== "PUT" || request.headers.get("origin") === appOrigin + ); +} diff --git a/applications/service-topology/src/server/repository.ts b/applications/service-topology/src/server/repository.ts new file mode 100644 index 00000000..2c0d48af --- /dev/null +++ b/applications/service-topology/src/server/repository.ts @@ -0,0 +1,36 @@ +import { and, eq, sql } from "drizzle-orm"; +import type { Database } from "./database"; +import { graphs, revisions } from "./schema"; +import type { GraphDocument, SavedGraph } from "../shared/types"; +export class StaleRevision extends Error {} +function view(row: typeof graphs.$inferSelect): SavedGraph { + return { ...row, updatedAt: row.updatedAt.toISOString() }; +} +export async function readGraph(db: Database, id: string) { + const [row] = await db.select().from(graphs).where(eq(graphs.id, id)); + return row ? view(row) : undefined; +} +export async function saveGraph( + db: Database, + id: string, + baseRevision: number, + document: GraphDocument, +) { + return db.transaction(async (tx) => { + const [saved] = await tx + .update(graphs) + .set({ + revision: baseRevision + 1, + document, + updatedAt: sql`clock_timestamp()`, + }) + .where(and(eq(graphs.id, id), eq(graphs.revision, baseRevision))) + .returning(); + if (!saved) + throw new StaleRevision("The graph changed. Reload before saving."); + await tx + .insert(revisions) + .values({ graphId: id, revision: saved.revision, document }); + return view(saved); + }); +} diff --git a/applications/service-topology/src/server/schema.ts b/applications/service-topology/src/server/schema.ts new file mode 100644 index 00000000..1b9e6389 --- /dev/null +++ b/applications/service-topology/src/server/schema.ts @@ -0,0 +1,44 @@ +import { + pgSchema, + uuid, + text, + integer, + customType, + timestamp, + primaryKey, +} from "drizzle-orm/pg-core"; +import type { GraphDocument } from "../shared/types"; +const topology = pgSchema("service_topology"); +// Bun 1.4 serializes JSONB parameters itself. Drizzle's default JSONB string +// encoder would encode a JSON string a second time, so pass the object through. +const graphJson = customType<{ + data: GraphDocument; + driverData: GraphDocument; +}>({ + dataType: () => "jsonb", + toDriver: (value) => value, + fromDriver: (value) => value, +}); +export const graphs = topology.table("graphs", { + id: uuid("id").primaryKey(), + title: text("title").notNull(), + revision: integer("revision").notNull(), + document: graphJson("document").notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); +export const revisions = topology.table( + "revisions", + { + graphId: uuid("graph_id") + .notNull() + .references(() => graphs.id), + revision: integer("revision").notNull(), + document: graphJson("document").notNull(), + savedAt: timestamp("saved_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [primaryKey({ columns: [table.graphId, table.revision] })], +); diff --git a/applications/service-topology/src/shared/types.ts b/applications/service-topology/src/shared/types.ts new file mode 100644 index 00000000..dea86593 --- /dev/null +++ b/applications/service-topology/src/shared/types.ts @@ -0,0 +1,17 @@ +export type ServiceKind = "web" | "api" | "worker" | "database"; +export type ServiceNode = { + id: string; + type: "service"; + position: { x: number; y: number }; + data: { label: string; kind: ServiceKind }; +}; +export type ServiceEdge = { id: string; source: string; target: string }; +export type GraphDocument = { nodes: ServiceNode[]; edges: ServiceEdge[] }; +export type SavedGraph = { + id: string; + title: string; + revision: number; + document: GraphDocument; + updatedAt: string; +}; +export const DEMO_GRAPH_ID = "67bc2cf0-5b0f-45c8-bf6a-7b4bb5155a50"; diff --git a/applications/service-topology/tests/browser/editor.spec.ts b/applications/service-topology/tests/browser/editor.spec.ts new file mode 100644 index 00000000..7840e8b1 --- /dev/null +++ b/applications/service-topology/tests/browser/editor.spec.ts @@ -0,0 +1,92 @@ +import { expect, test } from "@playwright/test"; +test("edit, connect, layout, save, reload and stale tab conflict", async ({ + page, + context, +}) => { + const other = await context.newPage(); + await page.goto("/"); + await other.goto("/"); + await expect(page.getByTestId("revision")).not.toHaveText("—"); + const originalRevision = Number( + await page.getByTestId("revision").innerText(), + ); + await page.getByRole("button", { name: "+ Add service" }).click(); + await page.getByTestId("service-card").filter({ hasText: "New api" }).click(); + await page + .getByRole("textbox", { name: "Service name" }) + .fill("Inventory API"); + const node = page + .locator(".react-flow__node") + .filter({ hasText: "Inventory API" }); + const prior = await node.boundingBox(); + if (!prior) throw new Error("Node not rendered"); + await page.mouse.move(prior.x + 100, prior.y + 55); + await page.mouse.down(); + await page.mouse.move(prior.x + 160, prior.y + 105, { steps: 8 }); + await page.mouse.up(); + await expect(page.getByTestId("save-state")).toHaveText("Unsaved changes"); + // React Flow's actual source/target handles create an edge. + const edgeCount = await page.locator(".react-flow__edge").count(); + const source = await page + .locator(".react-flow__node") + .filter({ hasText: "Inventory API" }) + .locator(".react-flow__handle.source") + .boundingBox(); + const target = await page + .locator(".react-flow__node") + .filter({ hasText: "Checkout API" }) + .locator(".react-flow__handle.target") + .boundingBox(); + if (!source || !target) throw new Error("Handles not rendered"); + await page.mouse.move( + source.x + source.width / 2, + source.y + source.height / 2, + ); + await page.mouse.down(); + await page.mouse.move( + target.x + target.width / 2, + target.y + target.height / 2, + { steps: 12 }, + ); + await page.mouse.up(); + await expect(page.locator(".react-flow__edge")).toHaveCount(edgeCount + 1); + await page.getByRole("button", { name: "Auto layout" }).click(); + await expect( + page.getByRole("button", { name: "Save revision" }), + ).toBeEnabled(); + await page.getByRole("button", { name: "Save revision" }).click(); + await expect(page.getByTestId("revision")).toHaveText( + String(originalRevision + 1).padStart(2, "0"), + ); + await page.reload(); + await expect( + page.getByTestId("service-card").filter({ hasText: "Inventory API" }), + ).toBeVisible(); + await other.getByRole("button", { name: "+ Add service" }).click(); + await other.getByRole("button", { name: "Save revision" }).click(); + await expect(other.getByTestId("save-state")).toHaveText("Save conflict"); + other.once("dialog", (dialog) => dialog.accept()); + await other.getByRole("button", { name: "Reload saved revision" }).click(); + await expect(other.getByTestId("revision")).toHaveText( + String(originalRevision + 1).padStart(2, "0"), + ); + await page.locator(".react-flow__pane").click({ position: { x: 20, y: 20 } }); + await page.screenshot({ + path: process.env.SCREENSHOT_PATH || "test-results/topology.png", + fullPage: true, + }); + await other.close(); + await page + .getByTestId("service-card") + .filter({ hasText: "Inventory API" }) + .click(); + await page.getByRole("button", { name: "Remove service" }).click(); + await page.getByRole("button", { name: "Save revision" }).click(); + await expect(page.getByTestId("revision")).toHaveText( + String(originalRevision + 2).padStart(2, "0"), + ); + await page.reload(); + await expect( + page.getByTestId("service-card").filter({ hasText: "Inventory API" }), + ).toHaveCount(0); +}); diff --git a/applications/service-topology/tests/database.test.ts b/applications/service-topology/tests/database.test.ts new file mode 100644 index 00000000..c3191471 --- /dev/null +++ b/applications/service-topology/tests/database.test.ts @@ -0,0 +1,191 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { connectDatabase } from "../src/server/database"; +import { createApi } from "../src/server/app"; +import type { GraphDocument } from "../src/shared/types"; +let runtime: Awaited>; +let admin: Awaited>; +let id: string; +const doc: GraphDocument = { + nodes: [ + { + id: "11111111-1111-4111-8111-111111111111", + type: "service", + position: { x: 10, y: 20 }, + data: { label: "Test API", kind: "api" }, + }, + ], + edges: [], +}; +beforeAll(async () => { + if (!process.env.DATABASE_TEST_ADMIN_URL) + throw new Error( + "Set DATABASE_TEST_ADMIN_URL to the migration login for a dedicated test database.", + ); + runtime = await connectDatabase(); + admin = await connectDatabase(process.env.DATABASE_TEST_ADMIN_URL); +}); +beforeEach(async () => { + id = crypto.randomUUID(); + await admin.client.begin(async (tx) => { + await tx`SET LOCAL ROLE topology_owner`; + await tx`INSERT INTO service_topology.graphs(id,title,revision,document) VALUES (${id},'Test',1,${doc}::jsonb)`; + await tx`INSERT INTO service_topology.revisions(graph_id,revision,document) VALUES (${id},1,${doc}::jsonb)`; + }); +}); +afterEach(async () => { + await admin.client.begin(async (tx) => { + await tx`SET LOCAL ROLE topology_owner`; + await tx`DELETE FROM service_topology.graphs WHERE id=${id}`; + }); +}); +afterAll(async () => { + await runtime?.client.close(); + await admin?.client.close(); +}); +function request(body: unknown) { + return createApi(runtime.db).handle( + new Request("http://127.0.0.1:3000/api/graphs/" + id, { + method: "PUT", + headers: { + "content-type": "application/json", + origin: "http://127.0.0.1:3000", + }, + body: JSON.stringify(body), + }), + ); +} +describe("real Postgres saves", () => { + test("sixteen concurrent edits commit one revision", async () => { + const replies = await Promise.all( + Array.from({ length: 16 }, () => + request({ baseRevision: 1, document: doc }), + ), + ); + expect(replies.filter((r) => r.status === 200)).toHaveLength(1); + expect(replies.filter((r) => r.status === 409)).toHaveLength(15); + const row = + await runtime.client`SELECT revision,document,jsonb_typeof(document) AS document_type FROM service_topology.graphs WHERE id=${id}`; + expect(row[0].revision).toBe(2); + expect(row[0].document_type).toBe("object"); + expect(row[0].document).toEqual(doc); + const count = + await runtime.client`SELECT count(*)::int AS total FROM service_topology.revisions WHERE graph_id=${id}`; + expect(count[0].total).toBe(2); + }); + test("snapshot failure rolls current graph back", async () => { + await admin.client.begin(async (tx) => { + await tx`SET LOCAL ROLE topology_owner`; + await tx`INSERT INTO service_topology.revisions(graph_id,revision,document) VALUES(${id},2,${doc}::jsonb)`; + }); + const changed = structuredClone(doc); + changed.nodes[0].data.label = "Should roll back"; + expect((await request({ baseRevision: 1, document: changed })).status).toBe( + 500, + ); + const rows = + await runtime.client`SELECT revision,document,jsonb_typeof(document) AS document_type FROM service_topology.graphs WHERE id=${id}`; + expect(rows[0].revision).toBe(1); + expect(rows[0].document).toEqual(doc); + }); + test("rejects malformed and semantically invalid graph before storage", async () => { + expect( + ( + await request({ + baseRevision: 1, + document: { nodes: [{ bad: "shape" }], edges: [] }, + }) + ).status, + ).toBe(422); + const invalid = structuredClone(doc); + invalid.edges = [ + { + id: crypto.randomUUID(), + source: doc.nodes[0].id, + target: crypto.randomUUID(), + }, + ]; + expect((await request({ baseRevision: 1, document: invalid })).status).toBe( + 400, + ); + const duplicate = structuredClone(doc); + duplicate.nodes.push(duplicate.nodes[0]); + expect( + (await request({ baseRevision: 1, document: duplicate })).status, + ).toBe(400); + const huge = structuredClone(doc); + huge.nodes = Array.from({ length: 101 }, () => ({ + ...doc.nodes[0], + id: crypto.randomUUID(), + })); + expect((await request({ baseRevision: 1, document: huge })).status).toBe( + 422, + ); + const rows = + await runtime.client`SELECT revision FROM service_topology.graphs WHERE id=${id}`; + expect(rows[0].revision).toBe(1); + }); + test("database requires both graph arrays and rejects untrusted requests", async () => { + await expect( + Promise.resolve( + runtime.client`UPDATE service_topology.graphs SET document=${{ nodes: [] }}::jsonb WHERE id=${id}`, + ), + ).rejects.toThrow(); + await expect( + Promise.resolve( + runtime.client`UPDATE service_topology.graphs SET document=${{ nodes: null, edges: [] }}::jsonb WHERE id=${id}`, + ), + ).rejects.toThrow(); + const api = createApi(runtime.db); + expect( + ( + await api.handle( + new Request("http://untrusted.example:3000/api/graphs/" + id), + ) + ).status, + ).toBe(403); + expect( + ( + await api.handle( + new Request("http://127.0.0.1:3000/api/graphs/" + id, { + method: "PUT", + headers: { + origin: "http://untrusted.example", + "content-type": "application/json", + }, + body: JSON.stringify({ baseRevision: 1, document: doc }), + }), + ) + ).status, + ).toBe(403); + }); + test("runtime role cannot change schema or overwrite snapshots", async () => { + await expect( + Promise.resolve( + runtime.client`CREATE TABLE service_topology.forbidden(id int)`, + ), + ).rejects.toThrow(); + await expect( + Promise.resolve( + runtime.client`UPDATE service_topology.revisions SET document='{}' WHERE graph_id=${id}`, + ), + ).rejects.toThrow(); + await expect( + Promise.resolve( + runtime.client`DELETE FROM service_topology.revisions WHERE graph_id=${id}`, + ), + ).rejects.toThrow(); + await expect( + Promise.resolve( + runtime.client`UPDATE service_topology.graphs SET title='Other' WHERE id=${id}`, + ), + ).rejects.toThrow(); + }); +}); diff --git a/applications/service-topology/tests/graph.test.ts b/applications/service-topology/tests/graph.test.ts new file mode 100644 index 00000000..09ab1f1b --- /dev/null +++ b/applications/service-topology/tests/graph.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { graphProblem } from "../src/server/graph"; +import type { GraphDocument } from "../src/shared/types"; +const first = "11111111-1111-4111-8111-111111111111"; +const second = "22222222-2222-4222-8222-222222222222"; +const document = (): GraphDocument => ({ + nodes: [first, second].map((id) => ({ + id, + type: "service", + position: { x: 1, y: 2 }, + data: { label: "API", kind: "api" }, + })), + edges: [], +}); +describe("graph meaning", () => { + test("allows cycles between distinct services", () => { + const d = document(); + d.edges = [ + { id: crypto.randomUUID(), source: first, target: second }, + { id: crypto.randomUUID(), source: second, target: first }, + ]; + expect(graphProblem(d)).toBeUndefined(); + }); + test("rejects dangling connections", () => { + const d = document(); + d.edges = [ + { id: crypto.randomUUID(), source: first, target: crypto.randomUUID() }, + ]; + expect(graphProblem(d)).toContain("existing"); + }); + test("rejects repeated nodes and blank names", () => { + const d = document(); + d.nodes.push(d.nodes[0]); + expect(graphProblem(d)).toContain("unique"); + d.nodes.pop(); + d.nodes[0].data.label = " "; + expect(graphProblem(d)).toContain("blank"); + }); + test("rejects self connections and duplicates", () => { + const d = document(); + d.edges = [{ id: crypto.randomUUID(), source: first, target: first }]; + expect(graphProblem(d)).toContain("itself"); + d.edges = [ + { id: crypto.randomUUID(), source: first, target: second }, + { id: crypto.randomUUID(), source: first, target: second }, + ]; + expect(graphProblem(d)).toContain("one connection"); + }); + test("rejects non-finite coordinates", () => { + const d = document(); + d.nodes[0].position.x = Infinity; + expect(graphProblem(d)).toContain("finite"); + }); +}); diff --git a/applications/service-topology/tsconfig.json b/applications/service-topology/tsconfig.json new file mode 100644 index 00000000..e10361bc --- /dev/null +++ b/applications/service-topology/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "types": ["bun", "vite/client"] + }, + "include": ["src", "tests", "vite.config.ts", "playwright.config.ts"] +} diff --git a/applications/service-topology/vite.config.ts b/applications/service-topology/vite.config.ts new file mode 100644 index 00000000..1d8d85cc --- /dev/null +++ b/applications/service-topology/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +export default defineConfig({ + plugins: [react()], + server: { proxy: { "/api": "http://127.0.0.1:3000" } }, +});