From eb472109bd91b1032e077b9cd44019094ae8227f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 16:00:06 +0200 Subject: [PATCH 001/629] docs(foldkit): migration plan and parity harness scaffolding --- apps/web-foldkit/package.json | 41 ++++++++++ bun.lock | 119 +++++++++++++++++++++++++++-- docs/foldkit-migration.md | 91 ++++++++++++++++++++++ packages/ui-parity/package.json | 27 +++++++ packages/ui-parity/src/contract.ts | 59 ++++++++++++++ packages/ui-parity/tsconfig.json | 23 ++++++ 6 files changed, 352 insertions(+), 8 deletions(-) create mode 100644 apps/web-foldkit/package.json create mode 100644 docs/foldkit-migration.md create mode 100644 packages/ui-parity/package.json create mode 100644 packages/ui-parity/src/contract.ts create mode 100644 packages/ui-parity/tsconfig.json diff --git a/apps/web-foldkit/package.json b/apps/web-foldkit/package.json new file mode 100644 index 0000000000..ac3d1d1349 --- /dev/null +++ b/apps/web-foldkit/package.json @@ -0,0 +1,41 @@ +{ + "name": "@maple/web-foldkit", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev --port ${PORT:-3481} --host ${HOST:-127.0.0.1}", + "build": "vite build", + "test": "vitest run --passWithNoTests", + "typecheck": "tsrs --noEmit" + }, + "dependencies": { + "@effect/platform-browser": "4.0.4", + "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/geist-mono": "^5.3.0", + "@foldkit/ui": "0.168.0", + "@maple/domain": "workspace:*", + "@maple/effect-db": "workspace:*", + "@maple/query-engine": "workspace:*", + "@maple/query-model": "workspace:*", + "@maple/widgets": "workspace:*", + "@tanstack/db": "0.11.3", + "effect": "catalog:effect", + "foldkit": "0.168.0", + "class-variance-authority": "catalog:ui", + "clsx": "catalog:ui", + "tailwind-merge": "catalog:tailwind" + }, + "devDependencies": { + "@effect/language-service": "catalog:effect", + "@foldkit/devtools": "0.168.0", + "@foldkit/oxlint-plugin": "0.15.3", + "@foldkit/vite-plugin": "0.27.1", + "@tailwindcss/vite": "catalog:tailwind", + "@types/node": "catalog:tooling", + "tailwindcss": "catalog:tailwind", + "tw-animate-css": "catalog:tailwind", + "typescript": "catalog:tooling", + "vite": "catalog:", + "vitest": "catalog:" + } +} diff --git a/bun.lock b/bun.lock index 4b6b2dd894..890574b6ca 100644 --- a/bun.lock +++ b/bun.lock @@ -124,7 +124,7 @@ }, "apps/cli": { "name": "@maple/cli", - "version": "0.0.26", + "version": "0.0.27", "bin": { "maple": "./src/bin.ts", }, @@ -360,6 +360,39 @@ "woff2-encoder": "^2.0.0", }, }, + "apps/web-foldkit": { + "name": "@maple/web-foldkit", + "dependencies": { + "@effect/platform-browser": "4.0.4", + "@foldkit/ui": "0.168.0", + "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/geist-mono": "^5.3.0", + "@maple/domain": "workspace:*", + "@maple/effect-db": "workspace:*", + "@maple/query-engine": "workspace:*", + "@maple/query-model": "workspace:*", + "@maple/widgets": "workspace:*", + "@tanstack/db": "0.11.3", + "class-variance-authority": "catalog:ui", + "clsx": "catalog:ui", + "effect": "catalog:effect", + "foldkit": "0.168.0", + "tailwind-merge": "catalog:tailwind", + }, + "devDependencies": { + "@effect/language-service": "catalog:effect", + "@foldkit/devtools": "0.168.0", + "@foldkit/oxlint-plugin": "0.15.3", + "@foldkit/vite-plugin": "0.27.1", + "@tailwindcss/vite": "catalog:tailwind", + "@types/node": "catalog:tooling", + "tailwindcss": "catalog:tailwind", + "tw-animate-css": "catalog:tailwind", + "typescript": "catalog:tooling", + "vite": "catalog:", + "vitest": "catalog:", + }, + }, "examples/alchemy-maple": { "name": "@maple-examples/alchemy", "devDependencies": { @@ -373,7 +406,7 @@ }, "examples/effect-todo": { "name": "@maple-examples/effect-todo", - "version": "0.0.0", + "version": "0.0.1", "dependencies": { "@effect/atom-react": "catalog:effect", "@effect/platform-bun": "catalog:effect", @@ -549,7 +582,7 @@ }, "packages/alchemy-maple": { "name": "@maple-dev/alchemy", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@maple-dev/effect-sdk": "workspace:*", }, @@ -640,7 +673,7 @@ }, "packages/browser": { "name": "@maple-dev/browser", - "version": "0.10.1", + "version": "0.11.0", "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/instrumentation": "^0.222.0", @@ -757,7 +790,7 @@ }, "packages/effect-sdk": { "name": "@maple-dev/effect-sdk", - "version": "0.10.0", + "version": "0.10.1", "dependencies": { "std-env": "^4.0.0", }, @@ -936,6 +969,26 @@ "tailwindcss": "^4.0.0", }, }, + "packages/ui-parity": { + "name": "@maple/ui-parity", + "dependencies": { + "@maple/domain": "workspace:*", + "@maple/query-engine": "workspace:*", + "effect": "catalog:effect", + "parse5": "^8.0.1", + "pixelmatch": "^8.0.0", + "playwright": "catalog:", + "pngjs": "^7.0.0", + }, + "devDependencies": { + "@effect/language-service": "catalog:effect", + "@types/bun": "^1.4.2", + "@types/node": "catalog:tooling", + "@types/pngjs": "^6.0.5", + "typescript": "catalog:tooling", + "vitest": "catalog:", + }, + }, "packages/widgets": { "name": "@maple/widgets", "dependencies": { @@ -1278,6 +1331,8 @@ "@effect/language-service": ["@effect/language-service@0.87.4", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-AfFn4K45l84LtbQSwoPWc06nrg7OzfV3uJOnIUqimSOWTCIsY1IKBXZfjSsVTq7jDuL9Jtoz8//bZp9+rkEihg=="], + "@effect/platform-browser": ["@effect/platform-browser@4.0.4", "", { "peerDependencies": { "effect": "^4.0.4" } }, "sha512-2D+0/Vd+6gMpgR608OPYON4+QMvtbXyl2+HKIlayuXLmlVokI2KoIt37gm01CN+fyRFotBr3hSz4gzLd7xb3Hw=="], + "@effect/platform-bun": ["@effect/platform-bun@4.0.4", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.4" }, "peerDependencies": { "effect": "^4.0.4" } }, "sha512-L5xA9sF2RET7rqi18Q8yUcWq301o+jBMm9GWJixmJWGwzeipzMqjjPk+10vE8vUiHPbtdtJaBSIgs0AJ2jEF1g=="], "@effect/platform-node": ["@effect/platform-node@4.0.4", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.4", "undici": "^8.11.2" }, "peerDependencies": { "effect": "^4.0.4", "redis": ">=5.0.0 <7.0.0" } }, "sha512-SdoyQMrO5zmqBebb2fxDk0sHYyJjBiw4QmLdV2ZcWf3ATpQPge3B5sTLijZg49PadekOwNeO83o5TfdtiiFEig=="], @@ -1386,6 +1441,14 @@ "@floating-ui/vue": ["@floating-ui/vue@1.1.11", "", { "dependencies": { "@floating-ui/dom": "^1.7.6", "@floating-ui/utils": "^0.2.11", "vue-demi": ">=0.13.0" } }, "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw=="], + "@foldkit/devtools": ["@foldkit/devtools@0.168.0", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "@effect/platform-browser": "4.0.0", "@foldkit/ui": ">=0.167.0", "effect": "4.0.0", "foldkit": ">=0.167.0" } }, "sha512-VjVOcrOQhgSdzVbrdG8Fjyz4LpBiPF2a82Gz3y+Z2MtI4b8Gf1IHRQSjtac/ugjkPsqOTGWwTQ7l4QaDrW00cw=="], + + "@foldkit/oxlint-plugin": ["@foldkit/oxlint-plugin@0.15.3", "", { "dependencies": { "effect-oxlint": "^0.3.4 || ^0.4.0" } }, "sha512-LprfdZcR4uIqzTqHhJWT9bjJiS1km4MsN9Yyqk8JcKAEHpAxwgneG3MvA7p5aPXVDpZE/zh5FCGzhRIz1MnscQ=="], + + "@foldkit/ui": ["@foldkit/ui@0.168.0", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "effect": "4.0.0", "foldkit": ">=0.167.0" } }, "sha512-PZNtVorDO6O45txndwouI8fOFHYcdZ3OP9amJKIR9gWJ13+rx+HaDRHYVnBHNp7fom/7qPEWd25wAJCpQF5i6w=="], + + "@foldkit/vite-plugin": ["@foldkit/vite-plugin@0.27.1", "", { "dependencies": { "@effect/platform-node": "4.0.0", "@effect/platform-node-shared": "4.0.0", "magic-string": "^1.4.2", "vitefu": "^1.1.3", "ws": "^8.21.3" }, "peerDependencies": { "effect": "4.0.0", "foldkit": ">=0.167.0", "vite": "^8.0.0" } }, "sha512-sQ8y2v7w21wTwm+NGxboGokbZULmH0kuAg2EV6JH+1rXDBATOCcXIToUmw9ZmaVR14/4SlZIygVnNCRRcDtfQQ=="], + "@fontsource-variable/geist": ["@fontsource-variable/geist@5.3.0", "", {}, "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA=="], "@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.3.0", "", {}, "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw=="], @@ -1638,10 +1701,14 @@ "@maple/ui": ["@maple/ui@workspace:packages/ui"], + "@maple/ui-parity": ["@maple/ui-parity@workspace:packages/ui-parity"], + "@maple/unitflow": ["@maple/unitflow@workspace:lib/unitflow"], "@maple/web": ["@maple/web@workspace:apps/web"], + "@maple/web-foldkit": ["@maple/web-foldkit@workspace:apps/web-foldkit"], + "@maple/widgets": ["@maple/widgets@workspace:packages/widgets"], "@mdit-vue/plugin-component": ["@mdit-vue/plugin-component@3.0.2", "", { "dependencies": { "@types/markdown-it": "^14.1.2", "markdown-it": "^14.1.0" } }, "sha512-Fu53MajrZMOAjOIPGMTdTXgHLgGU9KwTqKtYc6WNYtFZNKw04euSfJ/zFg8eBY/2MlciVngkF7Gyc2IL7e8Bsw=="], @@ -2298,6 +2365,8 @@ "@types/node": ["@types/node@24.19.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ=="], + "@types/pngjs": ["@types/pngjs@6.0.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ=="], + "@types/prettier": ["@types/prettier@3.0.0", "", { "dependencies": { "prettier": "*" } }, "sha512-mFMBfMOz8QxhYVbuINtswBp9VL2b4Y0QqYHwqLz3YbgtfAcat2Dl6Y1o4e22S/OVE6Ebl9m7wWiMT2lSbAs1wA=="], "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], @@ -2842,6 +2911,8 @@ "effect": ["effect@4.0.4", "", {}, "sha512-Sr50OeVSC3M5UkoUYUhFf3EULryhGaZZUf6OzDHTvaznOw7HRlxOKdF3/4nvQXHL22StOyMG3Cvo8Bt7xUp+Ng=="], + "effect-oxlint": ["effect-oxlint@0.4.0", "", { "dependencies": { "@oxlint/plugins": "^1.85.0" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-IA3ihFKGK0JoZVSPPgPP58988jj4PZ5/asFOhaSYHwDyRRRbkBJn7FX8RiPi++MFpXQvb+3fQpKdPzOvUK9xPQ=="], + "electron-to-chromium": ["electron-to-chromium@1.5.439", "", {}, "sha512-qu6QIPXhsb+CRcAiTMNjR4A1y/7tCYKkKjr5CZXRVih6qkDf79peZ2BpEU3qoDBNCRrcy3Mra3X9nG5oruuA7Q=="], "elkjs": ["elkjs@0.12.0", "", {}, "sha512-YZcKynxVxYoKIOEpywEPwCFdg+BTbxQRNf3pbwdDCvc8O3kQD8bmIwSxKU1eOTVc4Xo+VG9Te+575mlfvOrhEQ=="], @@ -2954,6 +3025,8 @@ "flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="], + "foldkit": ["foldkit@0.168.0", "", { "dependencies": { "parse5": "^8.0.1" }, "peerDependencies": { "@effect/platform-browser": "4.0.0", "effect": "4.0.0" } }, "sha512-FyAY34MdmF1JwdKiPq12uKHBmLny3QxAJ6WdakbXCBh8KR1lQfvdKEygQ/XAlxb2ICSmpVaFga5RgDSnxZsFiw=="], + "follow-redirects": ["follow-redirects@1.16.0", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], "fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="], @@ -3520,7 +3593,7 @@ "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], - "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], @@ -3564,6 +3637,8 @@ "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "pixelmatch": ["pixelmatch@8.0.0", "", { "dependencies": { "pngjs": "^7.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-cIqOLfh5k52us715qf3B3+kTOxZv+WDv77HN7xokLScYQGYQHi4m/ynoDIPpvF2LvCyUJGyvzY64i0QCXtzu1g=="], + "pkg-types": ["pkg-types@2.3.3", "", { "dependencies": { "confbox": "^0.3.1", "exsolve": "^1.1.1", "pathe": "^2.0.3" } }, "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w=="], "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], @@ -4162,6 +4237,10 @@ "@electric-sql/pglite-tools/@electric-sql/pglite": ["@electric-sql/pglite@0.3.15", "", {}, "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ=="], + "@foldkit/vite-plugin/@effect/platform-node": ["@effect/platform-node@4.0.0", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0", "undici": "^8.11.2" }, "peerDependencies": { "effect": "^4.0.0", "redis": ">=5.0.0 <7.0.0" } }, "sha512-/L+4MUbl8FejPKLHLROYSOebvHnfixkP8KiblSD/DE5uH5xENZBHaux4nH6XMnRP+lr8ajpEJFS8sUrmmxFJeQ=="], + + "@foldkit/vite-plugin/@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.22.0" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-VBXHJU9UXVZPdQGGDRjGXpo+TpOzRwZuMrdKAtIxi1qwcRftmacZ1zTgIrzBa0Z2RA2KVkoOecQUsTlt/nJZhw=="], + "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], "@inlang/paraglide-js/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], @@ -4262,6 +4341,8 @@ "@types/d3-sankey/@types/d3-shape": ["@types/d3-shape@1.3.12", "", { "dependencies": { "@types/d3-path": "^1" } }, "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q=="], + "@types/pngjs/@types/node": ["@types/node@26.6.3", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg=="], + "@types/sax/@types/node": ["@types/node@26.6.3", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg=="], "@types/ws/@types/node": ["@types/node@26.6.3", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg=="], @@ -4298,6 +4379,8 @@ "cheerio/htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], + "cheerio/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "cheerio/undici": ["undici@7.30.0", "", {}, "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ=="], "cheerio-select/boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], @@ -4344,6 +4427,10 @@ "h3/cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], + "hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "import-in-the-middle/es-module-lexer": ["es-module-lexer@3.0.2", "", {}, "sha512-BuIB67FngDSyQ/dpQNOZybwdEBDUGJQvOqwWr4ha/ufYiqzuEwPkKO2zLhRAgay28tStRIHUeWmszZAJo3GCOg=="], "ink/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -4388,10 +4475,12 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "parse5-htmlparser2-tree-adapter/domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + "parse5-htmlparser2-tree-adapter/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "parse5-parser-stream/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "pkg-types/confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], @@ -4474,6 +4563,8 @@ "@clerk/themes/@clerk/shared/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "@foldkit/vite-plugin/@effect/platform-node/@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.4", "", { "dependencies": { "@types/ws": "^8.18.2", "ws": "^8.22.0" }, "peerDependencies": { "effect": "^4.0.4" } }, "sha512-gh2KRmqGWrJ0FVL8GqnI1GpB+fCfCUI7sGiUZkxxBHyHj1t94rm6VDJffCfuVZWe6GpvYXtizifVczUrgOce8A=="], + "@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@inquirer/core/wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -4592,6 +4683,8 @@ "@types/d3-sankey/@types/d3-shape/@types/d3-path": ["@types/d3-path@1.0.11", "", {}, "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw=="], + "@types/pngjs/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + "@types/sax/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], "@types/ws/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], @@ -4666,6 +4759,8 @@ "cheerio/htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "cheerio/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], @@ -4676,6 +4771,10 @@ "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "hast-util-from-html/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "hast-util-raw/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "ink-confirm-input/ink-text-input/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], "miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="], @@ -4734,6 +4833,10 @@ "parse5-htmlparser2-tree-adapter/domhandler/domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + "parse5-htmlparser2-tree-adapter/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "parse5-parser-stream/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "run-jxa/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "run-jxa/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], diff --git a/docs/foldkit-migration.md b/docs/foldkit-migration.md new file mode 100644 index 0000000000..a232d6d0cb --- /dev/null +++ b/docs/foldkit-migration.md @@ -0,0 +1,91 @@ +# Foldkit migration plan + +Goal: replace the React frontends (`apps/web`, then `apps/local-ui`) with Foldkit apps that look +pixel-identical. Users notice nothing except speed and stability. Engineers get one Schema-typed +Model, Messages as facts and testable Commands. The method is the one Hazel used (Hazel +`docs/foldkit-migration.md`, harness at Hazel `286bbb07a:packages/ui-parity`). + +Acceptance test: `packages/ui-parity`. A screen is ported when every scenario for its route reports +`identical` (or `pass` with 0 perceptual px), with 0 behavior diffs, no console errors and no +unmocked endpoints. + +## 1. Starting point (inventoried 2026-10-17) + +| | | +| --- | --- | +| `apps/web` | SPA on TanStack Router via `lib/effect-router`, 131 route files (92 user-facing, 39 lab), 1,714 files, 287k lines. Components 182k lines; biggest: infra 24.6k, dashboard-builder 19k, agent-sessions 14.9k, settings 11.2k, integrations 10.9k, alerts 9.8k, service-map 9k, replays 9k | +| `@maple/ui` | ~80 Base UI primitives plus charts, plot, service map, traces, logs; 462 files, 55.7k lines. Also used by `apps/local-ui` (51 files), `apps/landing` (22), `apps/ai` (10) | +| `apps/local-ui` | Separate React + react-query SPA, 93 files, 11k lines, reuses `@maple/ui` | +| State | `@effect/atom-react` through `src/lib/effect-atom.ts`: 208 files, ~520 hook calls. 4 `AtomHttpApi` clients over `@maple/domain` (358 endpoints, 46 groups). 5 Electric / TanStack DB collections | +| Warehouse | One endpoint: `/internal/query-engine/execute-batch` (`src/api/warehouse/execute-batcher.ts`) | +| Auth | Clerk (41 files) or `self_hosted` root-password token (`VITE_MAPLE_AUTH_MODE`) | +| Server | Worker: API proxy, OG and chart images (`src/og`, takumi), meta rewriting. React-free, kept as is | +| Bundle | Startup 703.1 KB gzip (budget 706.5 KB); all JS 6.6 MB gzip, mostly lazy (elk, mermaid, shiki grammars, three, rrweb) | +| Tests | 205 node + 102 browser Vitest files in web (mostly RTL), 85 in ui, 10 Playwright perf specs on `/lab/bench/*` | +| Reusable verification | 80 lab pages with fixtures, perf specs, `perf/check-bundle-budget.ts`, `scripts/seed-demo`, `self_hosted` auth | + +React-only dependencies and replacements: + +| Legacy | Replacement | +| --- | --- | +| `@tanstack/react-router` + `lib/effect-router` | Foldkit flat route union, layouts as view helpers | +| `@effect/atom-react` atoms | Model fields, Commands from the existing Effect atom bodies, Subscriptions | +| `@base-ui/react` (43 ui files) | Kit on `@foldkit/ui`, hand-built where its DOM cannot match | +| `@tanstack/react-table` / `react-virtual` | `@tanstack/table-core` / `@tanstack/virtual-core` or `@foldkit/ui` VirtualList | +| `@tanstack/charts/react` adapter (1 file) | `@tanstack/charts` core in a Mount | +| `@xyflow/react` (26 files) | `@xyflow/system` in a Mount, or SVG + d3-zoom (spike S5) | +| `@react-three/fiber` | vanilla three in a Mount, lazy | +| `streamdown` | `@foldkit/markdown` view + mermaid/katex/shiki in lazy Mounts | +| `motion`, `react-day-picker`, `@tanstack/react-hotkeys` | vanilla motion in Mounts, Foldkit Calendar/DatePicker, key bindings | +| `@tanstack/react-db` `useLiveQuery` | `liveQuery` Subscription over the same collections (Hazel S2) | +| Clerk React components | `Clerk.mount*` in Mounts, auth state via `window.Clerk` listener | + +## 2. Strategy + +- New app `apps/web-foldkit` beside `apps/web`, cut over in one switch (Hazel §2.1). +- Legacy baseline pinned by `LEGACY_BASELINE_REF` in `packages/ui-parity/src/config.ts`. Re-pin only + in a dedicated commit that shows legacy-vs-legacy identical. +- Framework-free code is reused, not rewritten: `@maple/domain`, `@maple/query-engine`, + `@maple/widgets`, `lib/grid-engine`, `@maple/agent-sessions`, `apps/web/src/api/warehouse/*`, + formatting and colour helpers, collections. `tv()` and class composition move to + `*.styles.ts` files consumed by both kits; every extraction is proven neutral with + `parity run --baseline legacy --candidate legacy-head`. +- `@maple/ui`'s React kit stays until `apps/local-ui` is migrated (Phase 8); `apps/landing` keeps + only what it imports. + +## 3. Verification flow (`packages/ui-parity`) + +| Piece | Maple specifics | +| --- | --- | +| `build legacy\|legacy-head\|foldkit` | Static Vite builds with every `VITE_*` URL pointed at the fixture origin; legacy built from the pin in `~/.cache/maple-ui-parity/worktrees` | +| Fixture backend | `HttpApiBuilder` over the real `@maple/domain` groups, handlers per dataset, unmocked endpoint fails the variant | +| Warehouse | `execute-batch` compiled by the real query-engine and run on chDB (apps/cli's binding) seeded deterministically at the dataset's frozen `now` | +| Electric | Minimal `/v1/shape` server for the 5 collections | +| Auth | `self_hosted` token in localStorage by default; `window.Clerk` stub for Clerk-only screens | +| Behavioral parity | Call log per capture (endpoint + payload, including every warehouse query), diffed as multisets | +| Determinism | Frozen clock, UTC, DPR 1, seeded `Math.random`, frozen animations, canvas settle, fonts and images decoded, two identical screenshots in a row; console errors fail | +| Compare | Strict + perceptual px, structural style deltas grouped by root cause, ARIA snapshots, HTML report | +| `selfcheck`, `coverage` | Determinism; burndown from `routeTree.gen.ts` | +| `perf` | Existing `perf/*.spec.ts` against both builds, frame-cost traces, heap, bundle | +| `PARITY_PORT_BASE` | Parallel worktrees | + +## 4. Phases + +0. Harness, S1 skeleton (one screen identical), spikes: S2 bundle without route splitting, S3 charts + on canvas, S4 logs/traces tables at bench parity, S5 service map, S6 data layer. Decision records + in `docs/foldkit-decisions/`. If S2, S4 or S5 fails badly, stop and reconsider. +1. Foundations: styles extraction, kit with lab/gallery scenarios, icons generated from source. +2. Shell: sidebar, org/namespace switchers, time range picker, command palette, modals, toasts. +3. Areas in parallel: overview, services, traces, logs, metrics, errors, alerts, dashboards, infra, + service map, replays, agent sessions, investigations, code review, analytics, settings, + integrations, onboarding/auth, share. +4. Chat and AI surfaces. +5. Platform and polish: telemetry SDK, replay capture, accessibility, perf budgets. +6. Cutover: switch the deploy, service-worker tombstone if needed, delete `apps/web`. +7. `apps/local-ui` onto the Foldkit kit, then delete `@maple/ui`'s React kit. + +## 5. Definition of done (per screen) + +Identical scenarios at desktop and mobile (if responsive) in light and dark; default, empty, +loading, error and overflow states; 0 behavior diffs; Scene tests for update logic; ARIA roles +and names match. diff --git a/packages/ui-parity/package.json b/packages/ui-parity/package.json new file mode 100644 index 0000000000..46d64d47a8 --- /dev/null +++ b/packages/ui-parity/package.json @@ -0,0 +1,27 @@ +{ + "name": "@maple/ui-parity", + "private": true, + "type": "module", + "scripts": { + "parity": "bun run src/cli.ts", + "test": "vitest run --passWithNoTests", + "typecheck": "tsrs --noEmit" + }, + "dependencies": { + "@maple/domain": "workspace:*", + "@maple/query-engine": "workspace:*", + "effect": "catalog:effect", + "parse5": "^8.0.1", + "pixelmatch": "^8.0.0", + "playwright": "catalog:", + "pngjs": "^7.0.0" + }, + "devDependencies": { + "@effect/language-service": "catalog:effect", + "@types/bun": "^1.4.2", + "@types/node": "catalog:tooling", + "@types/pngjs": "^6.0.5", + "typescript": "catalog:tooling", + "vitest": "catalog:" + } +} diff --git a/packages/ui-parity/src/contract.ts b/packages/ui-parity/src/contract.ts new file mode 100644 index 0000000000..bbe5451681 --- /dev/null +++ b/packages/ui-parity/src/contract.ts @@ -0,0 +1,59 @@ +/** + * The seam between the capture side (cli, build, capture, compare, report) and the fixture + * backend (backend/*, fixtures/*). Both sides import only this file from each other. + */ + +/** Dataset chosen per request; the capture side sets it on every request it lets through. */ +export const DATASET_HEADER = "x-parity-dataset" +/** Capture id, so the backend can attribute each call to one variant's call log. */ +export const CAPTURE_HEADER = "x-parity-capture" + +/** One request the app made, as the backend saw it. */ +export interface CallLogEntry { + /** `"."` for HttpApi endpoints, `"warehouse:"` for each query inside an execute-batch, `"shape:"` for Electric. */ + readonly tag: string + /** Decoded payload / url params / query, normalized (sorted keys, random UUIDs replaced). */ + readonly payload: unknown +} + +export interface DatasetIdentity { + readonly orgId: string + readonly userId: string + readonly email: string + readonly name: string + readonly orgName: string + readonly orgSlug: string + readonly role: "admin" | "member" +} + +export interface DatasetSummary { + readonly name: string + /** Frozen wall clock for the browser (`page.clock.setFixedTime`) and the seeded warehouse. */ + readonly now: string + /** `null` = signed out. */ + readonly identity: DatasetIdentity | null + /** Which auth the browser runs: self-hosted token in localStorage, or the `window.Clerk` stub. */ + readonly auth: "self_hosted" | "clerk" +} + +export interface FixtureBackend { + /** Origin the builds are configured with (`VITE_API_BASE_URL` etc. are derived from it). */ + readonly apiOrigin: string + readonly electricOrigin: string + readonly datasets: ReadonlyArray + /** Calls recorded for one capture id, then forgotten. */ + readonly takeCalls: (captureId: string) => { + readonly calls: ReadonlyArray + /** Requests no handler answered: each one fails the variant. */ + readonly unmocked: ReadonlyArray + } + readonly close: () => Promise +} + +export interface StartBackendOptions { + /** `PARITY_PORT_BASE + 0` (API) and `+ 3` (Electric). */ + readonly apiPort: number + readonly electricPort: number +} + +export type StartBackend = (options: StartBackendOptions) => Promise diff --git a/packages/ui-parity/tsconfig.json b/packages/ui-parity/tsconfig.json new file mode 100644 index 0000000000..dbee41e6a3 --- /dev/null +++ b/packages/ui-parity/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node", "bun"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "plugins": [ + { + "name": "@effect/language-service", + "reportSuggestionsAsWarningsInTsc": true + } + ] + } +} From 62d19d5bfcc97634359ad78e7a68331ceec52f32 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 16:08:44 +0200 Subject: [PATCH 002/629] docs(foldkit): S2 bundle decision record --- docs/foldkit-decisions/s2-bundle.md | 115 ++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 docs/foldkit-decisions/s2-bundle.md diff --git a/docs/foldkit-decisions/s2-bundle.md b/docs/foldkit-decisions/s2-bundle.md new file mode 100644 index 0000000000..571dad4e52 --- /dev/null +++ b/docs/foldkit-decisions/s2-bundle.md @@ -0,0 +1,115 @@ +# S2: Can Maple ship as one Foldkit program without route splitting? + +Status: decided 2026-10-17. Foldkit 0.168. Measured on main at 2247b5973f. + +## Question + +A Foldkit program composes Model, update and view statically, so every page ships in the +initial bundle. Only code behind a dynamic `import()` inside a Mount or Command can split. This +spike measures what that costs Maple's web app and what can be done about it. + +## Method + +All numbers are gzip of the JS in the static import graph of `index.html` (the same walk as +`apps/web/perf/check-bundle-budget.ts`), from production `vite build` output. + +1. Baseline: the current build. +2. No splitting: `autoCodeSplitting: false` in the TanStack Router plugin, and a throwaway Vite + plugin that rewrites every app-level `import("x")` in `apps/web/src`, `packages/ui/src` and + `packages/widgets/src` into a hoisted static `import * as`. 26 dynamic imports were made + static (chat panel, command palette, chart registry, tool renderers, alert chart and others). + Kept lazy: elkjs, the three.js service-map view and the rrweb replay loader. +3. Foldkit approximation: the same build with the Mount-bound libraries and the dev-only lab + marked `external` (three, @react-three, mermaid, katex, streamdown, shiki, rrweb, @xyflow, + elkjs, cytoscape, marked, dialkit, `src/lab/`), then also the React-only libraries Foldkit + replaces (react, react-dom, @base-ui/react, @tanstack/react-*, router-core, clerk-react, + framer-motion, react-resizable-panels, react-day-picker, @shadcn/react, atom-react). + Externals drop their transitive dependencies, so this measures what remains for real. +4. Attribution: a sourcemap build, generated bytes per source mapped to a package or directory, + scaled to each chunk's gzip size. + +## Numbers + +| Build | Startup gzip | Startup raw | All JS gzip | +| --- | --- | --- | --- | +| Today (132 startup chunks) | 703.1 KB | 2302 KB | 6701 KB | +| No route splitting (26 chunks) | 2845.1 KB | 9840 KB | 6234 KB | +| No splitting, Mount libs and lab external | 1840.0 KB | 6319 KB | 1931 KB | +| Also React-only libs external | 1419.5 KB | 4834 KB | 1511 KB | +| Foldkit estimate (see below) | 1430 to 1580 KB | about 5 MB | | + +Brotli for the last measured row is 1080 KB (today 605 KB). + +The Foldkit estimate is 1419.5 KB plus the runtime plus whatever replaces the React data hooks. +The Foldkit docs put a minimal app at just under 90 KB gzip, but that number includes Effect, +which Maple already ships (79 KB), so the real runtime increment is closer to 15 to 25 KB. The +upper bound adds the full 90 KB and 70 KB for @tanstack/db core, which dropped out with +@tanstack/react-db and would only stay if collections are kept instead of moving into the Model. + +Largest chunks today (none in startup above 140 KB): elk.bundled 432, elk-worker 429, +message-response (katex) 327, three viewport 280, shiki wasm 227, shiki emacs-lisp 196, mermaid +parser 140, startup index 137, cytoscape 135, tanstack/db dashboard prefs 129, v2-atom-client 83. + +## Attribution of the no-splitting startup bundle (2845 KB) + +App components in `apps/web/src/components` 671 KB, React-only libraries 278 KB, markdown, +shiki and katex 265 KB, three and @react-three 235 KB, packages/domain 175 KB (130 of it the +HTTP contract), packages/ui 161 KB, mermaid stack 155 KB, dev-only lab and dialkit 140 KB, +web api/lib/hooks 116 KB, @tanstack/db and electric 111 KB, @tanstack/charts and d3 102 KB, +routes 88 KB, effect 80 KB, rrweb 56 KB, @xyflow 37 KB, query-engine 36 KB. + +Of the 1419.5 KB that remains after externals, about 81 percent is Maple code: components +594 KB (infra 73, dashboard-builder 56, icons 49, agent-sessions 47, settings 46, integrations +43, alerts 38, errors 23, replays 22, chat 19), domain 173 KB, packages/ui 154 KB, web +api/lib/hooks 114 KB, routes 81 KB, query-engine 36 KB. Libraries are only effect 79 KB, +@tanstack/charts plus d3 65 KB and small utilities. A view rewrite from JSX to Foldkit `h` +calls should land near the same size, so page code, not libraries, is what dominates. + +## Mitigations + +(a) Heavy libraries in Mounts. Mandatory and already assumed. It removes about 1000 KB of the +no-splitting bundle (including the lab, which must also be excluded from production through an +`import.meta.env.DEV` guard in the route table instead of today's `beforeLoad` 404). Mount +boundaries should also take the app code that only drives those libraries, such as +`components/service-map/three` (28 KB), the replay engine and the markdown renderer. + +(b) Embedding. Each area becomes its own `makeElement` program in its own chunk, started by +`Runtime.embed` from a Mount in a shell app. This can bring startup back to roughly today's +size (shell estimate 700 to 800 KB: contract, effect, layout, icons, ui primitives). The cost is +architectural: separate Models and runtimes per area, shared state (org, auth, time range, +region) passed through Flags and Schema-encoded Ports, the shell owns routing, leaving an area +disposes its runtime and its state, and DevTools and Scene tests see one area at a time. It +rebuilds route splitting by hand at a coarse grain and gives up the single Model. + +(c) Lazy view registry. Feasible on the current runtime: `update` and `view` are plain +functions and the program config takes only `Model` as a Schema, not Message. The root Model +Schema and the page Message types stay eager (small); a Command does `import("./pages/x")`, +stores the page's `update` and `view` in a module-level registry and emits `LoadedPage`; the +Model records which pages are loaded and the root delegates through the registry. Savings are +similar to (b) while keeping one Model. The cost is impurity: view and update read state outside +the Model, so replay, DevTools time travel and Scene tests must preload modules, and every page +needs a loading state. This is the design work the Foldkit docs say route splitting would take. + +(d) Accept the bigger bundle. Transfer at 1.5 MB gzip versus 703 KB: 4G at 9 Mbps 1.3 s versus +0.6 s, Lighthouse slow 4G 7.5 s versus 3.5 s, desktop at 50 Mbps 0.24 s versus 0.11 s. Parse +and compile goes from 2.3 MB to about 5 MB raw; expect roughly 100 to 200 ms more on a fast +desktop and several times that on a mid-range phone, plus eager evaluation of every page's +module-level Schemas. `apps/web/perf` has no cold-load or TTI measurement to compare against; +its specs measure render behavior, so the first step is to add one. A single chunk also means +every deploy invalidates the whole 1.5 MB; static vendor and area chunks (manual chunking of +static code) keep cache hits across deploys without changing what startup loads. Hazel's +Foldkit app ended at 987 KB; Maple would be about 1.5 times that. + +## Recommendation + +Go, with conditions. Ship one Foldkit program and accept a startup bundle of about 1.5 MB gzip +(budget 1.6 MB), because Maple is a signed-in, desktop-first dashboard where repeat visits hit +the cache and the extra cost is about 0.7 s of transfer on 4G and well under 0.3 s on desktop. +Conditions: (1) every heavy library listed above lives behind a Mount or Command `import()`, +enforced by a startup-graph check like today's `forbiddenStartupPatterns`; (2) the lab is +compiled out of production; (3) static code is manually chunked for cache stability; (4) a +cold-load TTI test is added before the first page moves, on the React app, so the Foldkit app +is measured against it. If that test shows the single bundle regressing TTI beyond what the +team accepts, adopt (c), the lazy view registry, for the largest areas (infra, dashboard +builder, agent sessions, settings, integrations). Embedding (b) is not recommended for page +areas; reserve it for genuinely independent widgets. From c9767d3d91f29bc73fd93920b4bba033e1f1a581 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 16:27:25 +0200 Subject: [PATCH 003/629] feat(ui-parity): capture, compare and report side of the parity harness --- packages/ui-parity/.parity/.gitignore | 3 + packages/ui-parity/README.md | 181 ++++++++ packages/ui-parity/src/behavior.ts | 150 +++++++ packages/ui-parity/src/build.ts | 74 ++++ packages/ui-parity/src/capture.ts | 339 ++++++++++++++ packages/ui-parity/src/cli.ts | 202 +++++++++ packages/ui-parity/src/codegen.ts | 119 +++++ packages/ui-parity/src/compare.ts | 318 ++++++++++++++ packages/ui-parity/src/config.ts | 114 +++++ packages/ui-parity/src/coverage.ts | 36 ++ packages/ui-parity/src/report.ts | 413 ++++++++++++++++++ packages/ui-parity/src/runtime/clerk-stub.ts | 184 ++++++++ packages/ui-parity/src/runtime/session.ts | 39 ++ packages/ui-parity/src/runtime/snapshot.ts | 249 +++++++++++ packages/ui-parity/src/runtime/stabilize.ts | 161 +++++++ .../ui-parity/src/runtime/vite-wrapper.ts | 47 ++ packages/ui-parity/src/scenarios.ts | 23 + packages/ui-parity/src/scenarios/lab.ts | 56 +++ packages/ui-parity/src/scenarios/routes.ts | 36 ++ packages/ui-parity/src/scenarios/types.ts | 49 +++ packages/ui-parity/src/selfcheck.ts | 54 +++ packages/ui-parity/src/serve.ts | 103 +++++ 22 files changed, 2950 insertions(+) create mode 100644 packages/ui-parity/.parity/.gitignore create mode 100644 packages/ui-parity/README.md create mode 100644 packages/ui-parity/src/behavior.ts create mode 100644 packages/ui-parity/src/build.ts create mode 100644 packages/ui-parity/src/capture.ts create mode 100644 packages/ui-parity/src/cli.ts create mode 100644 packages/ui-parity/src/codegen.ts create mode 100644 packages/ui-parity/src/compare.ts create mode 100644 packages/ui-parity/src/config.ts create mode 100644 packages/ui-parity/src/coverage.ts create mode 100644 packages/ui-parity/src/report.ts create mode 100644 packages/ui-parity/src/runtime/clerk-stub.ts create mode 100644 packages/ui-parity/src/runtime/session.ts create mode 100644 packages/ui-parity/src/runtime/snapshot.ts create mode 100644 packages/ui-parity/src/runtime/stabilize.ts create mode 100644 packages/ui-parity/src/runtime/vite-wrapper.ts create mode 100644 packages/ui-parity/src/scenarios.ts create mode 100644 packages/ui-parity/src/scenarios/lab.ts create mode 100644 packages/ui-parity/src/scenarios/routes.ts create mode 100644 packages/ui-parity/src/scenarios/types.ts create mode 100644 packages/ui-parity/src/selfcheck.ts create mode 100644 packages/ui-parity/src/serve.ts diff --git a/packages/ui-parity/.parity/.gitignore b/packages/ui-parity/.parity/.gitignore new file mode 100644 index 0000000000..2aded8e2fe --- /dev/null +++ b/packages/ui-parity/.parity/.gitignore @@ -0,0 +1,3 @@ +# Builds, captures and reports: all generated. +* +!.gitignore diff --git a/packages/ui-parity/README.md b/packages/ui-parity/README.md new file mode 100644 index 0000000000..af4c0349f2 --- /dev/null +++ b/packages/ui-parity/README.md @@ -0,0 +1,181 @@ +# @maple/ui-parity + +Pixel and behavior comparison of the legacy React frontend (`apps/web`) against the Foldkit +frontend (`apps/web-foldkit`). It is the acceptance test for the migration +(`docs/foldkit-migration.md` §3): a screen counts as ported when its scenarios come back `identical`. + +There is no root script. Run everything from the repo root with: + +```bash +bun run --cwd packages/ui-parity parity +``` + +```bash +parity build legacy # apps/web at LEGACY_BASELINE_REF (src/config.ts), in a pinned worktree +parity build legacy-head # apps/web from the working tree +parity build foldkit # apps/web-foldkit from the working tree +parity run # capture legacy and foldkit, diff, write the report +``` + +## Targets and builds + +| Target | Source | `serve` port | +| ------------- | ------------------------------------------------------------------------- | ------------ | +| `legacy` | `apps/web` at `LEGACY_BASELINE_REF`, detached worktree in `~/.cache/maple-ui-parity/worktrees` | base + 1 | +| `foldkit` | `apps/web-foldkit` (working tree) | base + 2 | +| `legacy-head` | `apps/web` (working tree) | base + 4 | + +- The pinned worktree is created on first use: `git worktree add --detach`, `bun install --frozen-lockfile`, + `bun run --cwd packages/effect-sdk build`. A `.parity-setup-done` marker makes a half-done setup retry. +- Builds are production `vite build`s with every URL pointed at the fixture backend (`buildEnv` in + `src/config.ts`): `VITE_API_BASE_URL`, `VITE_API_PUBLIC_URL`, `VITE_INGEST_URL`, + `VITE_MAPLE_SELF_INGEST_URL` = backend origin, `VITE_ELECTRIC_SYNC_URL` = Electric origin, + `VITE_MAPLE_REPLAY=off`, `VITE_MAPLE_CAPTURE=off`, a fake `pk_test` Clerk key. +- Vite runs with a wrapper config (`src/runtime/vite-wrapper.ts`, written to the app's + `node_modules/.parity/`). It points `envDir` at an empty directory so no `.env*` file leaks in, and + adds the lab-gate plugin (below). `PORTLESS_URL`, `VITE_COMMIT_SHA`, `VITE_MCP_URL` and + `VITE_MAPLE_REGION_APP_URLS` are stripped from the environment. +- **Auth flavors.** `--auth self_hosted` (default) or `--auth clerk` builds with that + `VITE_MAPLE_AUTH_MODE`; the Clerk flavor lands in `--clerk/`. A capture picks the flavor from + its dataset's `auth`. +- **Custom outputs.** `--out ` builds into `.parity/builds//`, and every command that takes + a build (`run --baseline/--candidate`, `selfcheck --target`, `capture`) accepts that name. Use it to + compare a working-tree change against a clean build of the same target. + +### The `/lab` gate + +`apps/web/src/routes/lab/route.tsx` throws `notFound()` unless `import.meta.env.DEV`. Parity builds are +production builds, so the wrapper config adds a `pre` transform that rewrites `import.meta.env.DEV` +to `true` in that one file only. A global `DEV` define would also switch on the dev-only banners, the +lab dials panel and the service map's layout debug overlay. No `apps/web` source changes. + +## How a capture works + +- **One origin.** Every build is loaded through `http://localhost:4800`, fulfilled inside Playwright + straight from the build directory (nothing binds it), so anything printing `location.origin` matches + across builds and port bases. Requests to any other origin than the fixture backend are aborted and + listed as `blockedRequests`. +- **Ports.** Offsets from `PARITY_PORT_BASE` (default 4790): backend `+0`, legacy `+1`, foldkit `+2`, + Electric `+3`, legacy-head `+4`. Give each worktree its own base (`PARITY_PORT_BASE=4900`). Bases that + reach a port Chromium refuses (or 5000/7000, macOS AirPlay) are rejected. Builds embed the backend + URLs, so a non-default base builds into `.parity/builds-/` and a build made for another base is refused. +- **Fixture backend.** `src/backend/index.ts` exports `startBackend` (the seam is `src/contract.ts`). + Every request to it carries `x-parity-dataset` (the scenario's dataset) and `x-parity-capture` + (`
`. Implements just enough of + * the protocol for the client to reach up-to-date: + * + * - initial request (`offset=-1`): every row as an insert, then an up-to-date control message + * - live request (`live=true`): up-to-date after ~150 ms; holding it open would exhaust the + * browser's six-connections-per-origin pool and starve the API + * + * Values are Postgres text, typed by the `electric-schema` header, as the real server sends them. + */ + +type PgType = "text" | "bool" | "int4" | "float8" | "jsonb" | "timestamptz" + +interface SyncedTableSpec { + readonly table: string + readonly key: ReadonlyArray + readonly columns: Readonly> +} + +const ts = "timestamptz" as const + +/** Column types per shape, from `packages/db/src/tables/` and `apps/electric-sync`'s registry. */ +export const SYNCED_TABLES = { + api_keys: { + table: "api_keys", + key: ["id"], + columns: { + id: "text", org_id: "text", name: "text", description: "text", key_prefix: "text", revoked: "bool", + revoked_at: ts, last_used_at: ts, expires_at: ts, scopes: "jsonb", kind: "text", created_at: ts, + created_by: "text", created_by_email: "text", + }, + }, + dashboards: { + table: "dashboards", + key: ["org_id", "id"], + columns: { + org_id: "text", id: "text", name: "text", payload_json: "jsonb", created_at: ts, updated_at: ts, + created_by: "text", updated_by: "text", version: "int4", + }, + }, + alert_rules: { + table: "alert_rules", + key: ["id"], + columns: { + id: "text", org_id: "text", name: "text", notes: "text", notification_template_json: "jsonb", enabled: "bool", + severity: "text", service_names_json: "jsonb", exclude_service_names_json: "jsonb", environments_json: "jsonb", + tags_json: "jsonb", signal_type: "text", comparator: "text", threshold: "float8", threshold_upper: "float8", + window_minutes: "int4", minimum_sample_count: "int4", consecutive_breaches_required: "int4", + consecutive_healthy_required: "int4", renotify_interval_minutes: "int4", apdex_threshold_ms: "float8", + query_builder_draft_json: "jsonb", raw_query_sql: "text", reducer: "text", group_by: "text", + destination_ids_json: "jsonb", query_spec_json: "jsonb", sample_count_strategy: "text", + no_data_behavior: "text", last_scheduled_at: ts, created_at: ts, updated_at: ts, created_by: "text", + updated_by: "text", + }, + }, + alert_rule_states: { + table: "alert_rule_states", + key: ["org_id", "rule_id", "group_key"], + columns: { + org_id: "text", rule_id: "text", group_key: "text", consecutive_breaches: "int4", consecutive_healthy: "int4", + last_status: "text", last_value: "float8", last_sample_count: "int4", last_evaluated_at: ts, last_error: "text", + updated_at: ts, + }, + }, + alert_incidents: { + table: "alert_incidents", + key: ["id"], + columns: { + id: "text", org_id: "text", rule_id: "text", incident_key: "text", rule_name: "text", group_key: "text", + signal_type: "text", severity: "text", status: "text", comparator: "text", threshold: "float8", + threshold_upper: "float8", first_triggered_at: ts, last_triggered_at: ts, resolved_at: ts, + last_observed_value: "float8", last_sample_count: "int4", last_evaluated_at: ts, dedupe_key: "text", + last_delivered_event_type: "text", last_notified_at: ts, hold_reason: "text", held_since: ts, + error_issue_id: "text", created_at: ts, updated_at: ts, + }, + }, + alert_destinations: { + table: "alert_destinations", + key: ["id"], + columns: { + id: "text", org_id: "text", name: "text", type: "text", enabled: "bool", config_json: "jsonb", + last_tested_at: ts, last_test_error: "text", created_at: ts, updated_at: ts, + }, + }, + alert_destinations_v2: { + table: "alert_destinations", + key: ["id"], + columns: { + id: "text", org_id: "text", name: "text", type: "text", enabled: "bool", config_json: "jsonb", + last_tested_at: ts, last_test_error: "text", disabled_at: ts, disabled_reason: "text", created_at: ts, + updated_at: ts, + }, + }, + investigation_v2: { + table: "investigations", + key: ["id"], + columns: { + id: "text", org_id: "text", status: "text", seeded_by: "text", subject_json: "jsonb", snapshot_json: "jsonb", + report_json: "jsonb", progress_json: "jsonb", severity: "text", confidence: "text", model: "text", + input_tokens: "int4", output_tokens: "int4", error: "text", created_by: "text", created_at: ts, + started_at: ts, diagnosed_at: ts, updated_at: ts, + }, + }, +} satisfies Readonly> + +const SYNCED_TABLE_NAMES: ReadonlyArray = [ + "api_keys", + "dashboards", + "alert_rules", + "alert_rule_states", + "alert_incidents", + "alert_destinations", + "alert_destinations_v2", + "investigation_v2", +] + +/** `?shape=` (the sync proxy the browser calls) or `?table=` (Electric itself). */ +const syncedTableOf = (url: URL): SyncedTableName | undefined => { + const requested = url.searchParams.get("shape") + const table = url.searchParams.get("table") + return SYNCED_TABLE_NAMES.find((name) => (requested === null ? SYNCED_TABLES[name].table === table : name === requested)) +} + +const encodeValue = (type: PgType, value: unknown): string | null => { + if (value === null || value === undefined) return null + if (type === "jsonb") return JSON.stringify(value) + if (type === "timestamptz" && typeof value === "number") return new Date(value).toISOString() + return String(value) +} + +const UP_TO_DATE = { headers: { control: "up-to-date", global_last_seen_lsn: "0" } } +export const LIVE_POLL_DELAY_MS = 150 + +const encodeRows = (synced: SyncedTableName, rows: ReadonlyArray, scope: string | null) => { + const spec = SYNCED_TABLES[synced] + const columns = Object.entries(spec.columns) + // The proxy filters investigations by `scope` (`"id" = $2`); every shape is already one org's rows. + const visible = scope === null ? rows : rows.filter((row) => row.id === scope) + return visible.map((row) => ({ + key: `"public"."${spec.table}"/${spec.key.map((column) => `"${String(row[column])}"`).join("/")}`, + value: Object.fromEntries(columns.map(([column, type]) => [column, encodeValue(type, row[column])])), + headers: { operation: "insert", relation: ["public", spec.table] }, + })) +} + +export const electricCorsHeaders = (request: Request): Record => ({ + "access-control-allow-origin": request.headers.get("origin") ?? "*", + "access-control-allow-credentials": "true", + "access-control-allow-headers": request.headers.get("access-control-request-headers") ?? "*", + "access-control-allow-methods": "GET, OPTIONS", + "access-control-expose-headers": + "electric-handle, electric-offset, electric-schema, electric-cursor, electric-up-to-date", + vary: "Authorization", +}) + +export interface SyncAnswer { + readonly response: Response | Promise + /** The shape a non-live request asked for, for the call log (`shape:
`). */ + readonly logged?: { readonly table: string; readonly payload: unknown } +} + +/** Answers one shape request from the dataset; live polls never enter the call log (timing-dependent). */ +export const handleSyncRequest = (request: Request, dataset: Dataset, authorized: boolean): SyncAnswer => { + const url = new URL(request.url) + const synced = syncedTableOf(url) + const cors = electricCorsHeaders(request) + if (synced === undefined) + return { response: Response.json({ message: `ui-parity: unknown shape ${url.search}` }, { status: 400, headers: cors }) } + if (!authorized) + return { response: Response.json({ message: "Unauthorized" }, { status: 401, headers: cors }) } + const spec = SYNCED_TABLES[synced] + const live = url.searchParams.get("live") === "true" + const headers = { + ...cors, + "content-type": "application/json", + "cache-control": "no-store", + "electric-handle": `parity-${dataset.name}-${synced}`, + "electric-offset": "0_0", + "electric-schema": JSON.stringify(Object.fromEntries(Object.entries(spec.columns).map(([column, type]) => [column, { type }]))), + "electric-up-to-date": "", + "electric-cursor": "0", + } + if (live) { + const body = JSON.stringify([UP_TO_DATE]) + return { response: Bun.sleep(LIVE_POLL_DELAY_MS).then(() => new Response(body, { headers })) } + } + const scope = url.searchParams.get("scope") + const body = JSON.stringify([...encodeRows(synced, dataset.synced[synced] ?? [], scope), UP_TO_DATE]) + return { + response: new Response(body, { headers }), + logged: { table: spec.table, payload: { shape: synced, ...(scope === null ? undefined : { scope }) } }, + } +} diff --git a/packages/ui-parity/src/backend/http-api.ts b/packages/ui-parity/src/backend/http-api.ts new file mode 100644 index 0000000000..488af75a80 --- /dev/null +++ b/packages/ui-parity/src/backend/http-api.ts @@ -0,0 +1,198 @@ +import { Effect, Layer, Option, Predicate, Schema } from "effect" +import { HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/http" +import { HttpApiBuilder } from "effect/http-api" +import type { Etag, HttpPlatform } from "effect/http" +import type { FileSystem, Path } from "effect" +import { MapleAiApi, MapleApi, MapleInternalApi } from "@maple/domain/http" +import { MapleApiV2 } from "@maple/domain/http/v2" +import type { Dataset, EndpointHandler } from "../fixtures/dataset.ts" +import type { CallLog } from "./call-log.ts" +import { middlewareContext, type DatasetOf } from "./middleware.ts" + +/** + * The four HttpApis the web app calls, served by the real `HttpApiBuilder` route machinery so + * requests decode and responses encode exactly as they do against `apps/api`. Every endpoint gets + * the same generic handler: dataset override, then default, else a 501 recorded as unmocked. + */ +const APIS = [MapleApi, MapleInternalApi, MapleAiApi, MapleApiV2] as const + +type FixtureApi = (typeof APIS)[number] + +/** The parts of an endpoint the generic handler reads; every `HttpApiEndpoint` has them. */ +export interface ReflectedEndpoint { + readonly identifier: string + readonly method: string + readonly path: string + readonly success: ReadonlySet +} + +export interface ReflectedGroup { + readonly identifier: string + readonly endpoints: Readonly> +} + +interface ReflectedApi { + readonly api: FixtureApi + readonly groups: ReadonlyArray +} + +interface ReflectedHandlers { + readonly handle: ( + identifier: string, + handler: (request: unknown) => Effect.Effect, + ) => ReflectedHandlers +} + +const reflect = (api: FixtureApi): ReflectedApi => { + const groups: Readonly> = api.groups + return { api, groups: Object.values(groups) } +} + +/** + * Registering every endpoint of four APIs from one loop is reflection: the per-endpoint types the + * builder tracks cannot be restated for a group chosen at runtime, so its generic signatures are + * erased here (and only here). Runtime behavior is the real builder's. + */ +// SAFETY: same runtime function; `group` only needs the API value, a group id it declares, and handlers for its endpoints. +const builderGroup = HttpApiBuilder.group as unknown as ( + api: FixtureApi, + identifier: string, + build: (handlers: ReflectedHandlers) => ReflectedHandlers, +) => Layer.Layer +// SAFETY: same runtime function; the group services it reads are provided by `builderGroup` layers below. +const builderLayer = HttpApiBuilder.layer as unknown as ( + api: FixtureApi, +) => Layer.Layer + +export const FIXTURE_APIS: ReadonlyArray = APIS.map(reflect) + +/** The endpoint tag the call log and dataset handlers use. */ +export const endpointTag = (group: ReflectedGroup, endpoint: ReflectedEndpoint) => + `${group.identifier}.${endpoint.identifier}` + +export interface ApiServerOptions { + readonly datasetOf: DatasetOf + readonly captureOf: (request: HttpServerRequest.HttpServerRequest) => string | null + readonly calls: CallLog + /** Dataset override first, then the generic default; `undefined` = unmocked. */ + readonly handlerFor: (dataset: Dataset, tag: string) => EndpointHandler | undefined + /** Endpoints whose call-log entries the handler writes itself (execute-batch logs each query). */ + readonly selfLogging: ReadonlySet +} + +const Json = Schema.fromJsonString(Schema.Unknown) +const decodeJson = Schema.decodeUnknownOption(Json) + +/** The request as it crossed the wire: route params, query string and JSON body. */ +const wireRequest = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const route = yield* HttpRouter.RouteContext + const url = new URL(request.url, "http://parity.local") + const text = request.method === "GET" || request.method === "HEAD" ? "" : yield* request.text + const body = text.length === 0 ? undefined : Option.getOrElse(decodeJson(text), () => text) + return { + ...(Object.keys(route.params).length === 0 ? undefined : { params: route.params }), + ...(url.search.length === 0 ? undefined : { query: Object.fromEntries(url.searchParams) }), + ...(body === undefined ? undefined : { body }), + } +}) + +const field = (value: unknown, key: string): unknown => + Predicate.hasProperty(value, key) ? value[key] : undefined + +const unmockedResponse = (tag: string) => + HttpServerResponse.jsonUnsafe( + { _tag: "@maple/ui-parity/Unmocked", message: `ui-parity: no fixture handler for ${tag}` }, + { status: 501 }, + ) + +/** Fixtures are written in wire form; decoding them with the success schema checks them too. */ +const successDecoder = (endpoint: ReflectedEndpoint) => { + const schemas = [...endpoint.success] + const [only, ...rest] = schemas + const schema = only !== undefined && rest.length === 0 ? only : Schema.Union(schemas) + // SAFETY: @maple/domain success schemas need no decoding services; `Top` only widens R to unknown. + return Schema.decodeUnknownEffect(schema) as (input: unknown) => Effect.Effect +} + +const genericHandler = (options: ApiServerOptions, group: ReflectedGroup, endpoint: ReflectedEndpoint) => { + const tag = endpointTag(group, endpoint) + const decodeSuccess = successDecoder(endpoint) + const logged = !options.selfLogging.has(tag) + return (request: unknown) => + Effect.gen(function* () { + const http = yield* HttpServerRequest.HttpServerRequest + const dataset = options.datasetOf(http) + const capture = options.captureOf(http) + const respond = options.handlerFor(dataset, tag) + if (respond === undefined) { + options.calls.recordUnmocked(capture, `${tag} (${endpoint.method} ${endpoint.path})`) + return unmockedResponse(tag) + } + if (logged) options.calls.record(capture, tag, yield* wireRequest) + const answer = respond({ + dataset, + payload: field(request, "payload"), + params: field(request, "params"), + query: field(request, "query"), + record: (entryTag, payload) => options.calls.record(capture, entryTag, payload), + }) + return yield* decodeSuccess(yield* answer).pipe( + Effect.catch((error) => { + options.calls.recordUnmocked(capture, `invalid-fixture:${tag}: ${error.message}`) + return Effect.succeed( + HttpServerResponse.jsonUnsafe( + { _tag: "@maple/ui-parity/InvalidFixture", message: `${tag}: ${error.message}` }, + { status: 500 }, + ), + ) + }), + ) + }) +} + +/** One API's groups, each endpoint on the generic handler, registered with the shared router. */ +const apiLayer = (reflected: ReflectedApi, options: ApiServerOptions) => + builderLayer(reflected.api).pipe( + Layer.provide( + Layer.mergeAll( + Layer.empty, + ...reflected.groups.map((group) => + builderGroup(reflected.api, group.identifier, (handlers) => + Object.values(group.endpoints).reduce( + (acc, endpoint) => acc.handle(endpoint.identifier, genericHandler(options, group, endpoint)), + handlers, + ), + ), + ), + ), + ), + ) + +/** All endpoints of the four APIs as routes, plus a catch-all that reports anything else. */ +export const makeApiWebHandler = (options: ApiServerOptions) => { + const middleware = middlewareContext(options.datasetOf, (request, description) => + options.calls.recordUnmocked(options.captureOf(request), description), + ) + const notFound = HttpRouter.add( + "*", + "*", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const path = new URL(request.url, "http://parity.local").pathname + options.calls.recordUnmocked(options.captureOf(request), `${request.method} ${path} (no such route)`) + return unmockedResponse(`${request.method} ${path}`) + }), + ) + const app = Layer.mergeAll(notFound, ...FIXTURE_APIS.map((api) => apiLayer(api, options))).pipe( + Layer.provide(Layer.succeedContext(middleware)), + Layer.provide(HttpServer.layerServices), + ) + return HttpRouter.toWebHandler(app, { disableLogger: true }) +} + +/** Every `"."` the four APIs declare, for coverage reports and tests. */ +export const allEndpointTags = (): ReadonlyArray => + FIXTURE_APIS.flatMap((api) => + api.groups.flatMap((group) => Object.values(group.endpoints).map((endpoint) => endpointTag(group, endpoint))), + ) diff --git a/packages/ui-parity/src/backend/index.ts b/packages/ui-parity/src/backend/index.ts new file mode 100644 index 0000000000..306ad72984 --- /dev/null +++ b/packages/ui-parity/src/backend/index.ts @@ -0,0 +1,3 @@ +/** The fixture backend's public surface; the capture side imports only `startBackend` (contract.ts). */ +export { CALL_LOG_PATH, startBackend, startFixtureBackend, warehouseFingerprint } from "./server.ts" +export { normalizePayload } from "./call-log.ts" diff --git a/packages/ui-parity/src/backend/main.ts b/packages/ui-parity/src/backend/main.ts new file mode 100644 index 0000000000..d503616ef4 --- /dev/null +++ b/packages/ui-parity/src/backend/main.ts @@ -0,0 +1,20 @@ +/** + * Dev entry: `bun run packages/ui-parity/src/backend/main.ts` serves the fixture backend on + * PARITY_PORT_BASE + 0 (API) and + 3 (Electric) until interrupted. + */ +import { startBackend } from "./index.ts" + +const base = Number(process.env.PARITY_PORT_BASE ?? "4790") +const startedAt = performance.now() +const backend = await startBackend({ apiPort: base, electricPort: base + 3 }) + +console.log(`ui-parity fixture backend ready in ${Math.round(performance.now() - startedAt)} ms`) +console.log(` api ${backend.apiOrigin}`) +console.log(` electric ${backend.electricOrigin}`) +console.log(` datasets ${backend.datasets.map((dataset) => dataset.name).join(", ")}`) + +const shutdown = () => { + void backend.close().then(() => process.exit(0)) +} +process.on("SIGINT", shutdown) +process.on("SIGTERM", shutdown) diff --git a/packages/ui-parity/src/backend/middleware.ts b/packages/ui-parity/src/backend/middleware.ts new file mode 100644 index 0000000000..434220ff68 --- /dev/null +++ b/packages/ui-parity/src/backend/middleware.ts @@ -0,0 +1,160 @@ +import { Context, Effect, Redacted } from "effect" +import { HttpServerRequest } from "effect/http" +import { HttpApiError } from "effect/http-api" +import { + ApiRequestValidationError, + ApiSchemaErrors, + ApiUnexpectedError, + ApiUnexpectedErrors, + CurrentTenant, + UnauthorizedError, +} from "@maple/domain/http" +import { + AuthorizationV2, + V2InvalidCredentials, + V2InvalidRequest, + V2ResponseSchemaFailure, + V2SchemaErrors, + V2UnexpectedErrors, + V2UnexpectedFailure, +} from "@maple/domain/http/v2" +import { OrgId, RoleName, UserId } from "@maple/domain/primitives" +import type { Dataset } from "../fixtures/dataset.ts" +import { acceptsBearer } from "./auth.ts" + +/** Resolves the dataset a request belongs to (from the `x-parity-dataset` header). */ +export type DatasetOf = (request: HttpServerRequest.HttpServerRequest) => Dataset + +/** Reports a response that failed its own schema: the fixture is wrong, so the variant must fail. */ +export type ReportInvalidFixture = (request: HttpServerRequest.HttpServerRequest, description: string) => void + +const tenantFor = (dataset: Dataset): CurrentTenant.TenantSchema | undefined => { + const identity = dataset.identity + if (identity === null) return undefined + const roles = + dataset.auth === "self_hosted" ? ["root"] : identity.role === "admin" ? ["org:admin"] : ["org:member"] + return new CurrentTenant.TenantSchema({ + orgId: OrgId.make(identity.orgId), + userId: UserId.make(identity.userId), + roles: roles.map((role) => RoleName.make(role)), + authMode: dataset.auth, + }) +} + +/** The tenant the bearer proves, or `undefined` for a 401 (signed out, or no fixture token). */ +const authenticate = (datasetOf: DatasetOf, credential: Redacted.Redacted) => + Effect.map(HttpServerRequest.HttpServerRequest, (request) => { + const dataset = datasetOf(request) + return acceptsBearer(dataset, Redacted.value(credential)) ? tenantFor(dataset) : undefined + }) + +const unauthorized = () => new UnauthorizedError({ message: "Invalid or missing credentials." }) + +const schemaErrorMessage = (kind: string, message: string) => `Invalid request ${kind.toLowerCase()}: ${message}` + +const UNEXPECTED_MESSAGE = "An unexpected error occurred on our end." + +/** Runs `effect` as the dataset's tenant, or fails with `onMissing()` like the API's 401. */ +const withTenant = ( + datasetOf: DatasetOf, + effect: Effect.Effect, + credential: Redacted.Redacted, + onMissing: () => E2, +): Effect.Effect | HttpServerRequest.HttpServerRequest> => + Effect.gen(function* () { + const tenant = yield* authenticate(datasetOf, credential) + if (tenant === undefined) return yield* Effect.fail(onMissing()) + return yield* Effect.provideService(effect, CurrentTenant.Context, tenant) + }) + +/** Request-decode failures become `onRequest(...)`; response-encode failures report the fixture. */ +const schemaBoundary = ( + effect: Effect.Effect, + report: (message: string) => Effect.Effect, + onRequest: (kind: string, message: string) => E1, + onResponse: () => E2, +): Effect.Effect => + Effect.catch(effect, (error) => + Effect.gen(function* () { + if (!HttpApiError.HttpApiSchemaError.is(error)) return yield* Effect.fail(error) + if (error.kind === "Body" || error.kind === "ResponseHeaders") { + yield* report(error.cause.message) + return yield* Effect.fail(onResponse()) + } + return yield* Effect.fail(onRequest(error.kind, error.cause.message)) + }), + ) + +/** + * Every middleware the four HttpApis declare. Bearer middlewares answer from the dataset; + * schema middlewares turn bad requests into the API's 400 envelopes, and a response that fails + * its schema into a 500 plus an `invalid-fixture` report. + */ +export const middlewareContext = (datasetOf: DatasetOf, reportInvalid: ReportInvalidFixture) => { + const report = (group: string, endpoint: string) => (message: string) => + Effect.flatMap(HttpServerRequest.HttpServerRequest, (request) => + Effect.sync(() => reportInvalid(request, `invalid-fixture:${group}.${endpoint}: ${message}`)), + ) + const sessionBearer = { + bearer: (effect: Effect.Effect, { credential }: { readonly credential: Redacted.Redacted }) => + withTenant(datasetOf, effect, credential, unauthorized), + } + return Context.make(CurrentTenant.Authorization, CurrentTenant.Authorization.of(sessionBearer)).pipe( + Context.add(CurrentTenant.SessionAuthorization, CurrentTenant.SessionAuthorization.of(sessionBearer)), + Context.add( + CurrentTenant.RegionlessSessionAuthorization, + CurrentTenant.RegionlessSessionAuthorization.of(sessionBearer), + ), + Context.add( + CurrentTenant.UserSessionAuthorization, + CurrentTenant.UserSessionAuthorization.of({ + bearer: (effect, { credential }) => + Effect.gen(function* () { + const tenant = yield* authenticate(datasetOf, credential) + if (tenant === undefined) return yield* Effect.fail(unauthorized()) + return yield* Effect.provideService(effect, CurrentTenant.CurrentUser, { userId: tenant.userId }) + }), + }), + ), + Context.add( + AuthorizationV2, + AuthorizationV2.of({ + bearer: (effect, { credential }) => + withTenant(datasetOf, effect, credential, () => V2InvalidCredentials.make()), + }), + ), + Context.add( + ApiSchemaErrors, + ApiSchemaErrors.of((effect, { endpoint, group }) => + schemaBoundary( + effect, + report(group.identifier, endpoint.identifier), + (kind, message) => + new ApiRequestValidationError({ message: schemaErrorMessage(kind, message), details: [message] }), + () => new ApiUnexpectedError({ message: UNEXPECTED_MESSAGE }), + ), + ), + ), + Context.add( + ApiUnexpectedErrors, + ApiUnexpectedErrors.of((effect) => + Effect.catchDefect(effect, () => Effect.fail(new ApiUnexpectedError({ message: UNEXPECTED_MESSAGE }))), + ), + ), + Context.add( + V2SchemaErrors, + V2SchemaErrors.of((effect, { endpoint, group }) => + schemaBoundary( + effect, + report(group.identifier, endpoint.identifier), + (kind, message) => V2InvalidRequest.make(schemaErrorMessage(kind, message)), + () => V2ResponseSchemaFailure.make(), + ), + ), + ), + Context.add( + V2UnexpectedErrors, + V2UnexpectedErrors.of((effect) => Effect.catchDefect(effect, () => Effect.fail(V2UnexpectedFailure.make()))), + ), + ) +} diff --git a/packages/ui-parity/src/backend/server.test.ts b/packages/ui-parity/src/backend/server.test.ts new file mode 100644 index 0000000000..70451efd32 --- /dev/null +++ b/packages/ui-parity/src/backend/server.test.ts @@ -0,0 +1,155 @@ +import { spawn, type ChildProcess } from "node:child_process" +import { fileURLToPath } from "node:url" +import { Schema } from "effect" +import { + QueryEngineExecuteBatchResponse, + QueryEngineExecuteRequest, + formatWarehouseDateTime, +} from "@maple/query-engine" +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { CAPTURE_HEADER, DATASET_HEADER } from "../contract.ts" +import { FROZEN_NOW_MS } from "../fixtures/identity.ts" + +// The backend needs `bun:ffi` for chDB, so the suite runs it the way a developer does: as a Bun +// child process on its own ports, read back through `/__parity/calls`. +const base = 20_000 + Math.floor(Math.random() * 20_000) +const api = `http://localhost:${base}` +const main = fileURLToPath(new URL("./main.ts", import.meta.url)) +const children: Array = [] + +beforeAll(async () => { + const child = spawn("bun", ["run", main], { env: { ...process.env, PARITY_PORT_BASE: String(base) } }) + children.push(child) + await new Promise((resolve, reject) => { + const output: Array = [] + child.stdout?.on("data", (chunk: Buffer) => { + output.push(chunk.toString()) + if (output.join("").includes("ready")) resolve() + }) + child.stderr?.on("data", (chunk: Buffer) => output.push(chunk.toString())) + child.on("exit", (code) => reject(new Error(`backend exited with ${code}: ${output.join("")}`))) + }) +}, 60_000) + +afterAll(() => { + children.forEach((child) => child.kill("SIGTERM")) +}) + +const login = async (dataset: string) => { + const response = await fetch(`${api}/api/auth/login`, { + method: "POST", + headers: { "content-type": "application/json", [DATASET_HEADER]: dataset }, + body: JSON.stringify({ password: "parity" }), + }) + const body: { readonly token: string } = await response.json() + return body.token +} + +/** The services facets request exactly as `getServicesFacets` (apps/web/src/api/warehouse/services.ts) builds it. */ +const servicesFacetsRequest = () => + new QueryEngineExecuteRequest({ + startTime: formatWarehouseDateTime(FROZEN_NOW_MS - 24 * 60 * 60 * 1000), + endTime: formatWarehouseDateTime(FROZEN_NOW_MS), + query: { kind: "facets" as const, source: "services" as const }, + }) + +const executeBatch = async (dataset: string, capture: string) => { + const token = await login(dataset) + const response = await fetch(`${api}/internal/query-engine/execute-batch`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${token}`, + [DATASET_HEADER]: dataset, + [CAPTURE_HEADER]: capture, + }, + body: JSON.stringify({ requests: [Schema.encodeUnknownSync(QueryEngineExecuteRequest)(servicesFacetsRequest())] }), + }) + return { status: response.status, text: await response.text() } +} + +const takeCalls = async (capture: string) => { + const response = await fetch(`${api}/__parity/calls?capture=${capture}`) + const body: { readonly calls: ReadonlyArray<{ readonly tag: string }>; readonly unmocked: ReadonlyArray } = + await response.json() + return body +} + +/** Decodes with the response schema the web app's batcher uses; failures and other kinds yield `undefined`. */ +const facetRows = (text: string) => { + const outcome = Schema.decodeUnknownSync(Schema.fromJsonString(QueryEngineExecuteBatchResponse))(text).results[0] + return outcome?.outcome === "success" && outcome.result.kind === "facets" ? outcome.result.data : undefined +} + +describe("execute-batch on the fixture warehouse", () => { + it("answers the services facets query with the default org's seeded services", async () => { + const { status, text } = await executeBatch("default", "facets-default") + expect(status).toBe(200) + const services = (facetRows(text) ?? []).filter((row) => row.facetType === "service").map((row) => row.name) + expect(services).toEqual( + expect.arrayContaining(["storefront", "checkout-api", "cart-svc", "catalog-api", "payment-svc", "auth-svc"]), + ) + }) + + it("answers the same query with no rows for a brand-new org", async () => { + const { status, text } = await executeBatch("empty", "facets-empty") + expect(status).toBe(200) + expect(facetRows(text)).toEqual([]) + }) + + it("returns byte-identical JSON for the same request", async () => { + const first = await executeBatch("default", "repeat-1") + const second = await executeBatch("default", "repeat-2") + expect(second.text).toBe(first.text) + }) + + it("logs each query under a fingerprint that does not depend on the capture", async () => { + await executeBatch("default", "log-a") + await executeBatch("default", "log-b") + const a = await takeCalls("log-a") + const b = await takeCalls("log-b") + expect(a.calls.map((call) => call.tag)).toEqual(b.calls.map((call) => call.tag)) + expect(a.calls[0]?.tag).toMatch(/^warehouse:[0-9a-f]{16}$/) + }) +}) + +describe("unmocked requests", () => { + it("reports an endpoint without a fixture handler and answers 501", async () => { + const token = await login("default") + const response = await fetch(`${api}/internal/query-engine/service-overview`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${token}`, + [CAPTURE_HEADER]: "unmocked-endpoint", + }, + body: JSON.stringify({ startTime: "2026-09-30 12:00:00", endTime: "2026-10-01 12:00:00" }), + }) + expect(response.status).toBe(501) + const { unmocked } = await takeCalls("unmocked-endpoint") + expect(unmocked).toEqual(["queryEngine.serviceOverview (POST /internal/query-engine/service-overview)"]) + }) + + it("reports a path no API declares", async () => { + await fetch(`${api}/internal/does-not-exist`, { headers: { [CAPTURE_HEADER]: "unmocked-path" } }) + const { unmocked } = await takeCalls("unmocked-path") + expect(unmocked).toEqual(["GET /internal/does-not-exist (no such route)"]) + }) +}) + +describe("auth", () => { + it("answers authenticated endpoints with 401 for a signed-out dataset", async () => { + const response = await fetch(`${api}/api/auth/session`, { + headers: { authorization: `Bearer ${await login("default")}`, [DATASET_HEADER]: "signed-out" }, + }) + expect(response.status).toBe(401) + }) + + it("resolves the session token to the dataset's org", async () => { + const response = await fetch(`${api}/api/auth/session`, { + headers: { authorization: `Bearer ${await login("default")}` }, + }) + const body: { readonly orgId: string; readonly authMode: string } = await response.json() + expect(body).toMatchObject({ orgId: "org_parity_acme", authMode: "self_hosted" }) + }) +}) diff --git a/packages/ui-parity/src/backend/server.ts b/packages/ui-parity/src/backend/server.ts new file mode 100644 index 0000000000..3e19e3f8c0 --- /dev/null +++ b/packages/ui-parity/src/backend/server.ts @@ -0,0 +1,163 @@ +import { Effect, Schema } from "effect" +import { + QueryEngineExecuteBatchRequest, + QueryEngineExecuteBatchResponse, + QueryEngineExecuteRequest, +} from "@maple/query-engine" +import { createHash } from "node:crypto" +import { CAPTURE_HEADER, DATASET_HEADER, type DatasetSummary, type StartBackend } from "../contract.ts" +import type { Dataset, EndpointHandler } from "../fixtures/dataset.ts" +import { DATASETS, DEFAULT_DATASET } from "../fixtures/datasets/index.ts" +import { defaultDataset } from "../fixtures/datasets/default.ts" +import { acceptsBearer } from "./auth.ts" +import { makeCallLog, normalizePayload } from "./call-log.ts" +import { defaultHandlers } from "./defaults.ts" +import { electricCorsHeaders, handleSyncRequest } from "./electric.ts" +import { makeApiWebHandler } from "./http-api.ts" +import { makeFixtureWarehouse, parityOrgId, seedWarehouse, type FixtureWarehouse } from "./warehouse.ts" + +/** + * One local process standing in for the whole backend: the HttpApis (`/api`, `/internal`, `/v2`) + * plus the warehouse on `apiPort`, Electric shapes on `electricPort`. Both frontends are built + * against these origins, so they can be captured by Playwright or opened side by side. + */ + +const encodeRequest = Schema.encodeUnknownEffect(QueryEngineExecuteRequest) +const encodeBatchResponse = Schema.encodeUnknownEffect(QueryEngineExecuteBatchResponse) +const isBatchRequest = Schema.is(QueryEngineExecuteBatchRequest) + +export class ParityBackendError extends Schema.TaggedError()("@maple/ui-parity/ParityBackendError", { + message: Schema.String, +}) {} + +/** Stable across runs and apps: a hash of the normalized wire request. */ +export const warehouseFingerprint = (normalized: unknown): string => + createHash("sha256").update(JSON.stringify(normalized)).digest("hex").slice(0, 16) + +/** + * `queryEngine.executeBatch`, answered like the API: every item through the real lowering on the + * warehouse, per-item outcomes. Each item is logged as `warehouse:`; the batch itself + * is not, because how the browser groups queries into batches depends on timing. + */ +const executeBatch = + (warehouse: FixtureWarehouse): EndpointHandler => + ({ dataset, payload, record }) => + Effect.gen(function* () { + if (!isBatchRequest(payload)) + return yield* new ParityBackendError({ message: "execute-batch payload did not decode" }) + const orgId = parityOrgId(dataset.identity?.orgId ?? "org_parity_signed_out") + const results = yield* Effect.forEach(payload.requests, (request) => + Effect.gen(function* () { + const normalized = normalizePayload(yield* encodeRequest(request)) + record(`warehouse:${warehouseFingerprint(normalized)}`, normalized) + return yield* warehouse.execute(orgId, request) + }), + ) + return yield* encodeBatchResponse(new QueryEngineExecuteBatchResponse({ results })) + }) + +/** `GET` returns and forgets one capture's calls: `{ calls, unmocked }`, as `takeCalls` does. */ +export const CALL_LOG_PATH = "/__parity/calls" + +const corsHeaders = (request: Request): Record => ({ + "access-control-allow-origin": request.headers.get("origin") ?? "*", + "access-control-allow-credentials": "true", + "access-control-allow-headers": request.headers.get("access-control-request-headers") ?? "*", + "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", + "access-control-max-age": "600", + vary: "Origin", +}) + +const withCors = (request: Request, response: Response): Response => { + const headers = new Headers(response.headers) + Object.entries(corsHeaders(request)).forEach(([key, value]) => headers.set(key, value)) + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }) +} + +const summary = (dataset: Dataset): DatasetSummary => ({ + name: dataset.name, + now: dataset.now, + identity: dataset.identity, + auth: dataset.auth, +}) + +const bearerOf = (request: Request): string => { + const header = request.headers.get("authorization") ?? "" + return header.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : "" +} + +export interface StartFixtureBackendOptions { + readonly apiPort: number + readonly electricPort: number + readonly datasets?: ReadonlyArray +} + +export const startFixtureBackend = async (options: StartFixtureBackendOptions) => { + const datasets = options.datasets ?? DATASETS + const byName = new Map(datasets.map((dataset) => [dataset.name, dataset])) + const fallback = byName.get(DEFAULT_DATASET) ?? datasets[0] ?? defaultDataset + const datasetNamed = (name: string | null) => (name === null ? fallback : byName.get(name)) + const chdb = await Effect.runPromise( + seedWarehouse( + datasets.map((dataset) => ({ orgId: dataset.identity?.orgId ?? `dataset:${dataset.name}`, rows: dataset.telemetry })), + ), + ) + const warehouse = makeFixtureWarehouse(chdb) + const calls = makeCallLog() + const builtIn = new Map(defaultHandlers).set("queryEngine.executeBatch", executeBatch(warehouse)) + const api = makeApiWebHandler({ + // Unknown names were already refused in `fetch` below, so this never falls back silently. + datasetOf: (request) => datasetNamed(request.headers[DATASET_HEADER] ?? null) ?? fallback, + captureOf: (request) => request.headers[CAPTURE_HEADER] ?? null, + calls, + handlerFor: (dataset, tag) => dataset.endpoints[tag] ?? builtIn.get(tag), + selfLogging: new Set(["queryEngine.executeBatch"]), + }) + const unknownDataset = (request: Request, name: string | null) => + Response.json( + { _tag: "@maple/ui-parity/UnknownDataset", message: `ui-parity: unknown dataset "${name}"` }, + { status: 400, headers: corsHeaders(request) }, + ) + + const apiServer = Bun.serve({ + port: options.apiPort, + idleTimeout: 0, + fetch: async (request) => { + if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: corsHeaders(request) }) + const url = new URL(request.url) + // Out-of-process access to `takeCalls` (the vitest suite runs the backend as a Bun child). + if (url.pathname === CALL_LOG_PATH) return Response.json(calls.take(url.searchParams.get("capture") ?? "")) + const name = request.headers.get(DATASET_HEADER) + if (datasetNamed(name) === undefined) return unknownDataset(request, name) + return withCors(request, await api.handler(request)) + }, + }) + const electricServer = Bun.serve({ + port: options.electricPort, + idleTimeout: 0, + fetch: (request) => { + if (request.method === "OPTIONS") + return new Response(null, { status: 204, headers: electricCorsHeaders(request) }) + const name = request.headers.get(DATASET_HEADER) + const dataset = datasetNamed(name) + if (dataset === undefined) return unknownDataset(request, name) + const answer = handleSyncRequest(request, dataset, acceptsBearer(dataset, bearerOf(request))) + if (answer.logged !== undefined) + calls.record(request.headers.get(CAPTURE_HEADER), `shape:${answer.logged.table}`, answer.logged.payload) + return answer.response + }, + }) + + return { + apiOrigin: `http://localhost:${apiServer.port}`, + electricOrigin: `http://localhost:${electricServer.port}`, + datasets: datasets.map(summary), + takeCalls: calls.take, + close: async () => { + await Promise.all([apiServer.stop(true), electricServer.stop(true)]) + await api.dispose() + }, + } +} + +export const startBackend: StartBackend = (options) => startFixtureBackend(options) diff --git a/packages/ui-parity/src/backend/warehouse.ts b/packages/ui-parity/src/backend/warehouse.ts new file mode 100644 index 0000000000..ca4b33c727 --- /dev/null +++ b/packages/ui-parity/src/backend/warehouse.ts @@ -0,0 +1,108 @@ +import { Effect } from "effect" +import { OrgId, UserId } from "@maple/domain/primitives" +import { latestSnapshotStatements } from "@maple/domain/generated/clickhouse-schema" +import type { QueryEngineBatchOutcome, QueryEngineExecuteRequest } from "@maple/query-engine" +import { makeWarehouseExecutor, WarehouseDriverError, type WarehouseSqlClient } from "@maple/query-engine/execution" +import { makeQueryEngineExecute } from "@maple/query-engine/runtime" +import { TELEMETRY_TABLES, type TelemetryRows } from "../fixtures/telemetry/rows.ts" +import { sharedChdb, type Chdb, type ParityChdbError } from "./chdb.ts" + +/** + * The fixture warehouse: the API's own lowering (`makeQueryEngineExecute`, which + * `QueryEngineService.execute` wraps in caches) over the real `makeWarehouseExecutor` + * (OrgId enforcement, settings, row decoding), with chDB as the driver. + */ + +/** TTLs are relative to the host clock, so seeded rows at a frozen `now` would age out. */ +const schemaStatements = latestSnapshotStatements.map((statement) => statement.replace(/\nTTL [^\n]*/g, "")) + +const INSERT_CHUNK_ROWS = 5000 + +const insertRows = (chdb: Chdb, table: string, rows: ReadonlyArray) => + Effect.forEach( + Array.from({ length: Math.ceil(rows.length / INSERT_CHUNK_ROWS) }, (_, index) => + rows.slice(index * INSERT_CHUNK_ROWS, (index + 1) * INSERT_CHUNK_ROWS), + ), + (chunk) => + chdb.run(`INSERT INTO ${table} FORMAT JSONEachRow\n${chunk.map((row) => JSON.stringify(row)).join("\n")}`), + { discard: true }, + ) + +export interface WarehouseSeed { + /** The OrgId every row is stamped with; datasets sharing an org share the first seed. */ + readonly orgId: string + readonly rows: TelemetryRows +} + +/** chDB lives for the whole process, so each org is seeded once however many backends start. */ +const seeded = new Map>() + +const once = (key: string, effect: Effect.Effect) => { + const existing = seeded.get(key) + if (existing !== undefined) return existing + const cached = Effect.runSync(Effect.cached(effect)) + seeded.set(key, cached) + return cached +} + +/** Creates the schema and seeds every org's rows; orgs are kept apart by OrgId, as in prd. */ +export const seedWarehouse = (seeds: ReadonlyArray): Effect.Effect => + Effect.gen(function* () { + const chdb = yield* sharedChdb + yield* once("schema", Effect.forEach(schemaStatements, (statement) => chdb.run(statement), { discard: true })) + yield* Effect.forEach( + seeds, + (seed) => + once( + `org:${seed.orgId}`, + Effect.forEach(TELEMETRY_TABLES, (table) => insertRows(chdb, table, seed.rows[table]), { discard: true }), + ), + { discard: true }, + ) + return chdb + }) + +const chdbClient = (chdb: Chdb): WarehouseSqlClient => ({ + sql: (statement) => + chdb.rows(statement.text).pipe( + Effect.map((data) => ({ data })), + Effect.mapError( + (error) => new WarehouseDriverError({ reason: "server", status: 500, message: error.message, cause: error }), + ), + ), + insert: () => + Effect.fail(new WarehouseDriverError({ reason: "config", message: "the fixture warehouse is read-only" })), +}) + +export interface FixtureWarehouse { + /** One execute-batch item, answered as the API answers it: a per-item success or failure outcome. */ + readonly execute: (orgId: OrgId, request: QueryEngineExecuteRequest) => Effect.Effect +} + +/** + * `kind: "tinybird-gateway"` picks the managed dialect prd runs (static managed capabilities, no + * `system.*` probes, Tinybird-restricted settings stripped); `createClient` routes it to chDB. + */ +export const makeFixtureWarehouse = (chdb: Chdb): FixtureWarehouse => { + const executor = makeWarehouseExecutor({ + createClient: () => Effect.succeed(chdbClient(chdb)), + resolveRoute: () => + Effect.succeed({ + source: "managed" as const, + config: { kind: "tinybird-gateway" as const, url: "chdb://parity", username: "", password: "", database: "default" }, + clientCacheKey: "parity", + }), + coalesceCapabilityProbes: true, + }) + const execute = makeQueryEngineExecute(executor) + const userId = UserId.make("user_parity_warehouse") + return { + execute: (orgId, request) => + execute({ orgId, userId, authMode: "self_hosted" }, request).pipe( + Effect.map((response): QueryEngineBatchOutcome => ({ outcome: "success", result: response.result })), + Effect.catch((error) => Effect.succeed({ outcome: "failure", error: error.error })), + ), + } +} + +export const parityOrgId = (raw: string): OrgId => OrgId.make(raw) diff --git a/packages/ui-parity/src/fixtures/dataset.ts b/packages/ui-parity/src/fixtures/dataset.ts new file mode 100644 index 0000000000..28fe26a5d2 --- /dev/null +++ b/packages/ui-parity/src/fixtures/dataset.ts @@ -0,0 +1,48 @@ +import type { Effect } from "effect" +import type { DatasetIdentity, DatasetSummary } from "../contract.ts" +import type { TelemetryRows } from "./telemetry/rows.ts" + +/** What a fixture endpoint handler sees: the wire-decoded request parts and its dataset. */ +export interface EndpointRequest { + readonly dataset: Dataset + readonly payload: unknown + readonly params: unknown + readonly query: unknown + /** Adds an entry to this capture's call log (execute-batch logs one per warehouse query). */ + readonly record: (tag: string, payload: unknown) => void +} + +/** + * A canned endpoint answer in its ENCODED (wire JSON) form: the server decodes it with the + * endpoint's success schema, so a fixture that drifts from the domain schema fails loudly. + * Fail with one of the endpoint's declared errors to answer with it. + */ +export type EndpointHandler = (request: EndpointRequest) => Effect.Effect + +/** Electric shapes the web app syncs (`apps/web/src/lib/collections/*`), by `shape=` name. */ +export type SyncedTableName = + | "api_keys" + | "dashboards" + | "alert_rules" + | "alert_rule_states" + | "alert_incidents" + | "alert_destinations" + | "alert_destinations_v2" + | "investigation_v2" + +/** A Postgres row as Electric would read it: timestamps as epoch-ms numbers or ISO strings, jsonb as values. */ +export type SyncedRow = Readonly> + +/** + * Everything both frontends see during a parity run. The same dataset feeds the legacy app and + * the Foldkit app, so any visual or behavioral difference is a UI difference. + */ +export interface Dataset extends DatasetSummary { + /** `null` = signed out: every authenticated endpoint answers 401 like the real API. */ + readonly identity: DatasetIdentity | null + /** Canned answers keyed `"."`; they win over the generic defaults. */ + readonly endpoints: Readonly> + readonly synced: Partial>> + /** Raw warehouse rows, all stamped with `identity.orgId` (or none for an empty org). */ + readonly telemetry: TelemetryRows +} diff --git a/packages/ui-parity/src/fixtures/datasets/default.ts b/packages/ui-parity/src/fixtures/datasets/default.ts new file mode 100644 index 0000000000..9811fd6af8 --- /dev/null +++ b/packages/ui-parity/src/fixtures/datasets/default.ts @@ -0,0 +1,188 @@ +import type { Dataset, SyncedRow } from "../dataset.ts" +import { acmeAdmin, DAY, FROZEN_NOW, fixtureUuid, HOUR, isoBefore, MINUTE } from "../identity.ts" +import { acmeShopTelemetry } from "../telemetry/acme-shop.ts" + +/** + * Acme Shop: six services over the last 24 h with the payment-svc 3.5.0 pool-exhaustion incident, + * logs and metrics, plus a few dashboards, alert rules and an open incident. + */ + +const org = acmeAdmin.orgId +const user = acmeAdmin.userId + +const dashboard = (index: number, name: string, ageMs: number): SyncedRow => { + const id = `dash_parity_${index}` + const createdAt = isoBefore(ageMs) + const updatedAt = isoBefore(ageMs / 2) + return { + org_id: org, + id, + name, + payload_json: { + id, + name, + timeRange: { type: "relative", value: "12h" }, + widgets: [], + createdAt, + updatedAt, + }, + created_at: createdAt, + updated_at: updatedAt, + created_by: user, + updated_by: user, + version: 1, + } +} + +const destinationId = fixtureUuid(1, 1) + +const rule = ( + index: number, + fields: { + readonly name: string + readonly severity: "warning" | "critical" + readonly signal: string + readonly threshold: number + readonly services: ReadonlyArray + readonly enabled?: boolean + }, +): SyncedRow => ({ + id: fixtureUuid(2, index), + org_id: org, + name: fields.name, + notes: null, + notification_template_json: null, + enabled: fields.enabled ?? true, + severity: fields.severity, + service_names_json: fields.services, + exclude_service_names_json: [], + environments_json: ["production"], + tags_json: [], + signal_type: fields.signal, + comparator: "gt", + threshold: fields.threshold, + threshold_upper: null, + window_minutes: 5, + minimum_sample_count: 10, + consecutive_breaches_required: 2, + consecutive_healthy_required: 2, + renotify_interval_minutes: 60, + apdex_threshold_ms: null, + query_builder_draft_json: null, + raw_query_sql: null, + reducer: "identity", + group_by: null, + destination_ids_json: [destinationId], + query_spec_json: null, + sample_count_strategy: null, + no_data_behavior: "skip", + last_scheduled_at: isoBefore(MINUTE), + created_at: isoBefore(14 * DAY), + updated_at: isoBefore(3 * DAY), + created_by: user, + updated_by: user, +}) + +const paymentLatencyRule = rule(2, { + name: "Payment p95 latency", + severity: "critical", + signal: "p95_latency", + threshold: 800, + services: ["payment-svc"], +}) + +const rules = [ + rule(1, { name: "Checkout error rate", severity: "critical", signal: "error_rate", threshold: 2, services: ["checkout-api"] }), + paymentLatencyRule, + rule(3, { name: "Storefront p99 latency", severity: "warning", signal: "p99_latency", threshold: 1500, services: ["storefront"] }), + rule(4, { name: "Catalog error rate", severity: "warning", signal: "error_rate", threshold: 5, services: ["catalog-api"], enabled: false }), +] + +/** Payment p95 has been breaching since the 3.5.0 rollout; everything else is healthy. */ +const ruleStates: ReadonlyArray = rules.map((row, index) => ({ + org_id: org, + rule_id: row.id, + group_key: "all", + consecutive_breaches: index === 1 ? 23 : 0, + consecutive_healthy: index === 1 ? 0 : 40, + last_status: index === 1 ? "breached" : "healthy", + last_value: index === 1 ? 1840.5 : 0.4, + last_sample_count: 312, + last_evaluated_at: isoBefore(MINUTE), + last_error: null, + updated_at: isoBefore(MINUTE), +})) + +const incidents: ReadonlyArray = [ + { + id: fixtureUuid(3, 1), + org_id: org, + rule_id: paymentLatencyRule.id, + incident_key: "payment-p95:all", + rule_name: "Payment p95 latency", + group_key: "all", + signal_type: "p95_latency", + severity: "critical", + status: "open", + comparator: "gt", + threshold: 800, + threshold_upper: null, + first_triggered_at: isoBefore(110 * MINUTE), + last_triggered_at: isoBefore(MINUTE), + resolved_at: null, + last_observed_value: 1840.5, + last_sample_count: 312, + last_evaluated_at: isoBefore(MINUTE), + dedupe_key: "payment-p95:all", + last_delivered_event_type: "trigger", + last_notified_at: isoBefore(50 * MINUTE), + hold_reason: null, + held_since: null, + error_issue_id: null, + created_at: isoBefore(110 * MINUTE), + updated_at: isoBefore(MINUTE), + }, +] + +const destination: SyncedRow = { + id: destinationId, + org_id: org, + name: "On-call webhook", + type: "webhook", + enabled: true, + config_json: { summary: "https://hooks.acme.test/maple", channelLabel: null }, + last_tested_at: isoBefore(5 * DAY), + last_test_error: null, + disabled_at: null, + disabled_reason: null, + created_at: isoBefore(30 * DAY), + updated_at: isoBefore(5 * DAY), +} + +export const defaultDataset: Dataset = { + name: "default", + now: FROZEN_NOW, + identity: acmeAdmin, + auth: "self_hosted", + endpoints: {}, + synced: { + dashboards: [ + dashboard(1, "Checkout health", 20 * DAY), + dashboard(2, "Payments incident review", 2 * HOUR), + dashboard(3, "Storefront web vitals", 45 * DAY), + ], + alert_rules: rules, + alert_rule_states: ruleStates, + alert_incidents: incidents, + alert_destinations: [destination], + alert_destinations_v2: [destination], + api_keys: [], + }, + telemetry: acmeShopTelemetry({ + orgId: org, + now: new Date(FROZEN_NOW), + hours: 24, + peakTracesPerMinute: 6, + seed: 7, + }), +} diff --git a/packages/ui-parity/src/fixtures/datasets/empty.ts b/packages/ui-parity/src/fixtures/datasets/empty.ts new file mode 100644 index 0000000000..4765847847 --- /dev/null +++ b/packages/ui-parity/src/fixtures/datasets/empty.ts @@ -0,0 +1,14 @@ +import type { Dataset } from "../dataset.ts" +import { FROZEN_NOW, newOrgAdmin } from "../identity.ts" +import { emptyTelemetry } from "../telemetry/rows.ts" + +/** A brand-new org: signed in, no telemetry, no dashboards or alerts. */ +export const emptyDataset: Dataset = { + name: "empty", + now: FROZEN_NOW, + identity: newOrgAdmin, + auth: "self_hosted", + endpoints: {}, + synced: {}, + telemetry: emptyTelemetry, +} diff --git a/packages/ui-parity/src/fixtures/datasets/index.ts b/packages/ui-parity/src/fixtures/datasets/index.ts new file mode 100644 index 0000000000..8edee6201f --- /dev/null +++ b/packages/ui-parity/src/fixtures/datasets/index.ts @@ -0,0 +1,8 @@ +import type { Dataset } from "../dataset.ts" +import { defaultDataset } from "./default.ts" +import { emptyDataset } from "./empty.ts" +import { signedOutDataset } from "./signed-out.ts" + +/** Every dataset the fixture backend serves, by name. Requests without a header get `default`. */ +export const DATASETS: ReadonlyArray = [defaultDataset, emptyDataset, signedOutDataset] +export const DEFAULT_DATASET = defaultDataset.name diff --git a/packages/ui-parity/src/fixtures/datasets/signed-out.ts b/packages/ui-parity/src/fixtures/datasets/signed-out.ts new file mode 100644 index 0000000000..24cc5370a7 --- /dev/null +++ b/packages/ui-parity/src/fixtures/datasets/signed-out.ts @@ -0,0 +1,14 @@ +import type { Dataset } from "../dataset.ts" +import { FROZEN_NOW } from "../identity.ts" +import { emptyTelemetry } from "../telemetry/rows.ts" + +/** An anonymous visitor: authenticated endpoints answer 401 and login is refused. */ +export const signedOutDataset: Dataset = { + name: "signed-out", + now: FROZEN_NOW, + identity: null, + auth: "self_hosted", + endpoints: {}, + synced: {}, + telemetry: emptyTelemetry, +} diff --git a/packages/ui-parity/src/fixtures/identity.ts b/packages/ui-parity/src/fixtures/identity.ts new file mode 100644 index 0000000000..1a67b448a3 --- /dev/null +++ b/packages/ui-parity/src/fixtures/identity.ts @@ -0,0 +1,41 @@ +import type { DatasetIdentity } from "../contract.ts" + +/** Frozen wall clock shared by every dataset: browser clock, warehouse seed and fixture rows. */ +export const FROZEN_NOW_MS = Date.UTC(2026, 9, 1, 12, 0, 0) +export const FROZEN_NOW = new Date(FROZEN_NOW_MS).toISOString() + +/** `FROZEN_NOW` minus a duration, as an ISO string (what timestamptz columns carry). */ +export const isoBefore = (ms: number): string => new Date(FROZEN_NOW_MS - ms).toISOString() + +export const MINUTE = 60_000 +export const HOUR = 60 * MINUTE +export const DAY = 24 * HOUR + +/** The Acme Shop admin; same person in every dataset that has an org. */ +export const acmeAdmin: DatasetIdentity = { + orgId: "org_parity_acme", + userId: "user_parity_ada", + email: "ada@acme.test", + name: "Ada Lovelace", + orgName: "Acme Shop", + orgSlug: "acme-shop", + role: "admin", +} + +/** A brand-new org with no telemetry: every page shows its empty state. */ +export const newOrgAdmin: DatasetIdentity = { + orgId: "org_parity_new", + userId: "user_parity_grace", + email: "grace@newco.test", + name: "Grace Hopper", + orgName: "NewCo", + orgSlug: "newco", + role: "admin", +} + +/** + * Deterministic UUIDs for fixture rows. Version 7 layout on purpose: the call log only rewrites + * v4 UUIDs (random ones the app mints), so ids the app echoes back stay distinguishable. + */ +export const fixtureUuid = (namespace: number, index: number): string => + `0199a${namespace.toString(16).padStart(3, "0")}-0000-7000-8000-${index.toString(16).padStart(12, "0")}` diff --git a/packages/ui-parity/src/fixtures/prng.ts b/packages/ui-parity/src/fixtures/prng.ts new file mode 100644 index 0000000000..cfe0f9866e --- /dev/null +++ b/packages/ui-parity/src/fixtures/prng.ts @@ -0,0 +1,52 @@ +export type NonEmpty = readonly [A, ...ReadonlyArray] + +/** Seeded PRNG (mulberry32): the same seed always yields the same fixture rows. */ +export interface Prng { + /** Uniform in [0, 1). */ + readonly next: () => number + readonly int: (minInclusive: number, maxInclusive: number) => number + readonly chance: (probability: number) => boolean + readonly pick: (items: NonEmpty) => A + readonly weighted: (items: NonEmpty) => A + readonly hex: (length: number) => string + /** Multiplicative jitter around 1, e.g. `spread(0.3)` is in [0.7, 1.3). */ + readonly spread: (amount: number) => number +} + +export const makePrng = (seed: number): Prng => { + const state = { value: seed >>> 0 } + const next = () => { + state.value = (state.value + 0x6d2b79f5) >>> 0 + let t = state.value + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } + const int = (min: number, max: number) => min + Math.floor(next() * (max - min + 1)) + const pick = (items: NonEmpty): A => items[Math.floor(next() * items.length)] ?? items[0] + const weighted = (items: NonEmpty): A => { + const total = items.reduce((sum, [, weight]) => sum + weight, 0) + const target = next() * total + const found = items.reduce<{ readonly acc: number; readonly hit: A | undefined }>( + (state, [item, weight]) => + state.hit !== undefined + ? state + : state.acc + weight > target + ? { acc: state.acc + weight, hit: item } + : { acc: state.acc + weight, hit: undefined }, + { acc: 0, hit: undefined }, + ) + return found.hit ?? items[0][0] + } + const hex = (length: number) => + Array.from({ length }, () => Math.floor(next() * 16).toString(16)).join("") + return { + next, + int, + chance: (probability) => next() < probability, + pick, + weighted, + hex, + spread: (amount) => 1 - amount + next() * 2 * amount, + } +} diff --git a/packages/ui-parity/src/fixtures/telemetry/acme-shop.ts b/packages/ui-parity/src/fixtures/telemetry/acme-shop.ts new file mode 100644 index 0000000000..0d6b16d956 --- /dev/null +++ b/packages/ui-parity/src/fixtures/telemetry/acme-shop.ts @@ -0,0 +1,672 @@ +import { makePrng, type NonEmpty, type Prng } from "../prng.ts" +import { formatMicros, formatSeconds, type TelemetryRows, type WarehouseRow } from "./rows.ts" + +/** + * The Acme Shop scenario from `scripts/seed-demo/` (same services, routes, call graph and incident), + * generated straight into warehouse rows with a seeded PRNG: no OTLP, no network, same rows every run. + */ + +interface ServiceSpec { + readonly name: string + readonly language: "nodejs" | "go" | "python" + readonly version: string + /** Rolled out `minutesBeforeNow` before the frozen clock; earlier spans carry `version`. */ + readonly deploy?: { readonly version: string; readonly minutesBeforeNow: number } +} + +const STOREFRONT: ServiceSpec = { + name: "storefront", + language: "nodejs", + version: "5.8.2", + deploy: { version: "5.9.0", minutesBeforeNow: 600 }, +} +const CHECKOUT: ServiceSpec = { name: "checkout-api", language: "nodejs", version: "4.2.0" } + +export const ACME_SERVICES: ReadonlyArray = [ + STOREFRONT, + CHECKOUT, + { name: "cart-svc", language: "go", version: "1.22.1", deploy: { version: "1.23.0", minutesBeforeNow: 330 } }, + { name: "catalog-api", language: "python", version: "2.14.0", deploy: { version: "2.15.0", minutesBeforeNow: 1140 } }, + { name: "payment-svc", language: "nodejs", version: "3.4.1", deploy: { version: "3.5.0", minutesBeforeNow: 120 } }, + { name: "auth-svc", language: "go", version: "2.0.3" }, +] + +/** The payment-svc 3.5.0 rollout exhausts its pool: payment queries time out until the frozen `now`. */ +const INCIDENT_MINUTES = 120 +const PODS_PER_SERVICE = 2 +const NODES = ["ip-10-0-12-84", "ip-10-0-13-201", "ip-10-0-21-17", "ip-10-0-22-140"] as const +const SLUGS = ["linen-overshirt", "trail-runner-2", "canvas-tote", "merino-beanie", "field-watch"] as const + +type SpanKind = "Server" | "Client" | "Internal" | "Producer" | "Consumer" + +interface Failure { + readonly type: string + readonly message: string +} + +/** One span to emit; children run sequentially after `selfMs / 2` of the parent's own work. */ +interface Call { + readonly service: string + readonly name: string + readonly kind: SpanKind + readonly selfMs: number + readonly attributes: Readonly> + readonly children: ReadonlyArray + readonly failure?: Failure + /** Server spans log their request; `route` and `method` feed the log body and http metric. */ + readonly http?: { readonly method: string; readonly route: string; readonly path: string } +} + +interface Context { + readonly rng: Prng + /** Inside the payment-svc incident window. */ + readonly incident: boolean +} + +const host = (service: string) => `${service}.shop.svc.cluster.local` + +const server = ( + service: string, + method: string, + route: string, + path: string, + selfMs: number, + children: ReadonlyArray, + failure?: Failure, +): Call => ({ + service, + name: `${method} ${route}`, + kind: "Server", + selfMs, + children, + http: { method, route, path }, + attributes: { + "http.request.method": method, + "http.route": route, + "url.path": path, + "url.scheme": "http", + "server.address": host(service), + "network.protocol.version": "1.1", + }, + ...(failure === undefined ? undefined : { failure }), +}) + +/** The caller's client span around a call into another service's server span. */ +const hop = (caller: string, callee: Call): Call => ({ + service: caller, + name: callee.name, + kind: "Client", + selfMs: 1, + children: [callee], + attributes: { + "http.request.method": callee.http?.method ?? "GET", + "url.template": callee.http?.route ?? "", + "url.full": `http://${host(callee.service)}:8080${callee.http?.path ?? ""}`, + "server.address": host(callee.service), + "server.port": "8080", + "peer.service": callee.service, + }, +}) + +const queryText = (op: string, table: string): string => + op === "SELECT" + ? `SELECT * FROM ${table} WHERE id = $1` + : op === "INSERT" + ? `INSERT INTO ${table} VALUES ($1, $2, $3)` + : `UPDATE ${table} SET version = version + 1 WHERE id = $1` + +const postgres = (service: string, op: string, table: string, ms: number, failure?: Failure): Call => ({ + service, + name: `${op} shop.${table}`, + kind: "Client", + selfMs: ms, + children: [], + attributes: { + "db.system.name": "postgresql", + "db.system": "postgresql", + "db.namespace": "shop", + "db.operation.name": op, + "db.collection.name": table, + "db.query.text": queryText(op, table), + "server.address": "orders-db.shop.svc.cluster.local", + "server.port": "5432", + }, + ...(failure === undefined ? undefined : { failure }), +}) + +const redis = (service: string, op: string, key: string, ms: number): Call => ({ + service, + name: op, + kind: "Client", + selfMs: ms, + children: [], + attributes: { + "db.system.name": "redis", + "db.system": "redis", + "db.namespace": "cache", + "db.operation.name": op, + "db.query.text": `${op} ${key}`, + "server.address": "redis.shop.svc.cluster.local", + "server.port": "6379", + }, +}) + +const external = (service: string, method: string, hostname: string, path: string, ms: number): Call => ({ + service, + name: `${method} ${hostname}`, + kind: "Client", + selfMs: ms, + children: [], + attributes: { + "http.request.method": method, + "url.full": `https://${hostname}${path}`, + "server.address": hostname, + "server.port": "443", + }, +}) + +const internal = (service: string, name: string, ms: number): Call => ({ + service, + name, + kind: "Internal", + selfMs: ms, + children: [], + attributes: {}, +}) + +const POOL_TIMEOUT: Failure = { + type: "ConnectionTimeout", + message: "timed out acquiring a connection from the pool after 5000ms", +} + +/** Payment queries: 3-9 ms normally, about 11% pool timeouts (5 s) during the incident. */ +const paymentQuery = (ctx: Context, op: string, normalMs: number): Call => + ctx.incident && ctx.rng.chance(0.11) + ? postgres("payment-svc", op, "payments", 5000 + ctx.rng.int(0, 40), POOL_TIMEOUT) + : postgres("payment-svc", op, "payments", ctx.incident ? 540 * ctx.rng.spread(0.4) : normalMs) + +const session = (ctx: Context, caller: string): Call => + hop( + caller, + server("auth-svc", "GET", "/session", "/session", 2, [redis("auth-svc", "GET", `session:${ctx.rng.hex(8)}`, 1)]), + ) + +const productId = (ctx: Context) => String(ctx.rng.int(10000, 99999)) + +const catalogProduct = (ctx: Context): Call => { + const id = productId(ctx) + const failure = ctx.rng.chance(0.002) ? { type: "KeyError", message: "'variant_id'" } : undefined + return server( + "catalog-api", + "GET", + "/products/{id}", + `/products/${id}`, + 6, + [ + redis("catalog-api", "GET", `product:${id}`, 1), + postgres("catalog-api", "SELECT", "products", 4), + postgres("catalog-api", "SELECT", "variants", 3), + ], + failure, + ) +} + +const home = (ctx: Context): Call => + server("storefront", "GET", "/", "/", 14, [ + session(ctx, "storefront"), + hop( + "storefront", + server("catalog-api", "GET", "/products", "/products", 8, [ + redis("catalog-api", "GET", "catalog:featured", 1), + postgres("catalog-api", "SELECT", "products", 6), + // The N+1: one variants query per featured product. + ...Array.from({ length: ctx.rng.int(12, 16) }, () => postgres("catalog-api", "SELECT", "variants", 2)), + ]), + ), + ]) + +const productPage = (ctx: Context): Call => { + const slug = ctx.rng.pick(SLUGS) + const failure = ctx.rng.chance(0.004) + ? { type: "TypeError", message: "Cannot read properties of undefined (reading 'price')" } + : undefined + return server( + "storefront", + "GET", + "/product/[slug]", + `/product/${slug}`, + 14, + [hop("storefront", catalogProduct(ctx)), internal("storefront", "render ProductPage", 9)], + failure, + ) +} + +const cartAdd = (ctx: Context): Call => { + const cart = productId(ctx) + return server("storefront", "POST", "/api/cart", "/api/cart", 5, [ + session(ctx, "storefront"), + hop( + "storefront", + server("cart-svc", "POST", "/carts/{id}/items", `/carts/${cart}/items`, 3, [ + redis("cart-svc", "HSET", `cart:${cart}`, 1), + ]), + ), + ]) +} + +const cartView = (ctx: Context): Call => { + const cart = productId(ctx) + return server("storefront", "GET", "/cart", "/cart", 14, [ + session(ctx, "storefront"), + hop( + "storefront", + server("cart-svc", "GET", "/carts/{id}", `/carts/${cart}`, 2, [redis("cart-svc", "HGETALL", `cart:${cart}`, 1)]), + ), + ]) +} + +const checkout = (ctx: Context): Call => { + const cart = productId(ctx) + return server("storefront", "POST", "/api/checkout", "/api/checkout", 5, [ + hop( + "storefront", + server("checkout-api", "POST", "/checkout", "/checkout", 4, [ + session(ctx, "checkout-api"), + hop( + "checkout-api", + server("cart-svc", "GET", "/carts/{id}", `/carts/${cart}`, 2, [ + redis("cart-svc", "HGETALL", `cart:${cart}`, 1), + ]), + ), + hop( + "checkout-api", + server("payment-svc", "POST", "/charges", "/charges", 3, [ + paymentQuery(ctx, "INSERT", 9), + external("payment-svc", "POST", "api.stripe.com", "/v1/payment_intents", 240), + postgres("payment-svc", "UPDATE", "payments", 4), + ]), + ), + { ...internal("checkout-api", "publish orders.created", 2), kind: "Producer" }, + ]), + ), + ]) +} + +const orderView = (ctx: Context): Call => { + const id = productId(ctx) + return server("storefront", "GET", "/order/[id]", `/order/ord_${ctx.rng.hex(8)}`, 14, [ + session(ctx, "storefront"), + hop("storefront", server("payment-svc", "GET", "/charges/{id}", `/charges/${id}`, 2, [paymentQuery(ctx, "SELECT", 3)])), + ]) +} + +const login = (ctx: Context): Call => + server("storefront", "POST", "/api/login", "/api/login", 5, [ + hop( + "storefront", + server("auth-svc", "POST", "/login", "/login", 3, [ + postgres("auth-svc", "SELECT", "users", 3), + internal("auth-svc", "argon2.verify", 38), + redis("auth-svc", "SET", `session:${ctx.rng.hex(8)}`, 1), + ]), + ), + ]) + +const ENTRY_POINTS: NonEmpty Call, number]> = [ + [home, 24], + [productPage, 30], + [cartAdd, 14], + [cartView, 8], + [checkout, 14], + [orderView, 16], + [login, 6], +] + +interface Resource { + readonly service: ServiceSpec + readonly attributes: Readonly> +} + +/** Pods and commit shas are derived from the service and rollout, so they never vary between runs. */ +const resourceFor = (service: ServiceSpec, pod: number, deployed: boolean): Resource => { + const version = deployed && service.deploy !== undefined ? service.deploy.version : service.version + const sha = Array.from(`${service.name}@${version}`) + .reduce((acc, char) => (Math.imul(acc, 31) + char.charCodeAt(0)) >>> 0, 7) + .toString(16) + .padStart(8, "0") + .repeat(5) + return { + service, + attributes: { + "service.name": service.name, + "service.version": version, + "service.namespace": "shop", + "service.instance.id": `${service.name}-${sha.slice(0, 9)}-p${pod}`, + "deployment.environment.name": "production", + "deployment.environment": "production", + "vcs.ref.head.revision": sha, + "telemetry.sdk.name": "opentelemetry", + "telemetry.sdk.language": service.language, + "cloud.provider": "aws", + "cloud.region": "us-east-1", + "k8s.cluster.name": "prod-us-east-1", + "k8s.namespace.name": "shop", + "k8s.deployment.name": service.name, + "k8s.pod.name": `${service.name}-${sha.slice(0, 9)}-p${pod}`, + "k8s.node.name": NODES[(service.name.length + pod) % NODES.length] ?? NODES[0], + }, + } +} + +interface HttpBucket { + count: number + sum: number + min: number + max: number + readonly buckets: Array +} + +/** `http.server.request.duration` bounds, in seconds, as the seed-demo histogram uses. */ +export const HTTP_BOUNDS = [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10] + +interface Sink { + readonly orgId: string + readonly rng: Prng + readonly resourceAt: (service: string, micros: number) => Resource + readonly traces: Array + readonly logs: Array + /** Keyed `service|route|method|status|5-minute bucket start`. */ + readonly http: Map +} + +interface Emitted { + readonly endMicros: number + readonly failure: Failure | undefined + /** The service whose server span failed, for the caller's `UpstreamError`. */ + readonly failedService: string | undefined +} + +const SEVERITY = { DEBUG: 5, INFO: 9, WARN: 13, ERROR: 17 } as const + +const pushLog = ( + sink: Sink, + resource: Resource, + micros: number, + severity: keyof typeof SEVERITY, + body: string, + attributes: Readonly>, + trace?: { readonly traceId: string; readonly spanId: string }, +) => + sink.logs.push({ + OrgId: sink.orgId, + Timestamp: formatMicros(micros), + TimestampTime: formatSeconds(micros), + TraceId: trace?.traceId ?? "", + SpanId: trace?.spanId ?? "", + TraceFlags: trace === undefined ? 0 : 1, + SeverityText: severity, + SeverityNumber: SEVERITY[severity], + ServiceName: resource.service.name, + Body: body, + ResourceSchemaUrl: "", + ResourceAttributes: resource.attributes, + ScopeSchemaUrl: "", + ScopeName: "maple-seed-demo", + ScopeVersion: "1.0.0", + ScopeAttributes: {}, + LogAttributes: attributes, + }) + +const recordHttp = (sink: Sink, call: Call, startMicros: number, durationMs: number, status: string) => { + if (call.http === undefined) return + const bucket = Math.floor(startMicros / 300_000_000) * 300_000_000 + const key = [call.service, call.http.route, call.http.method, status, bucket].join("|") + const seconds = durationMs / 1000 + const entry = sink.http.get(key) ?? { + count: 0, + sum: 0, + min: seconds, + max: seconds, + buckets: Array.from({ length: HTTP_BOUNDS.length + 1 }, () => 0), + } + entry.count += 1 + entry.sum += seconds + entry.min = Math.min(entry.min, seconds) + entry.max = Math.max(entry.max, seconds) + const index = HTTP_BOUNDS.findIndex((bound) => seconds <= bound) + const slot = index === -1 ? HTTP_BOUNDS.length : index + entry.buckets[slot] = (entry.buckets[slot] ?? 0) + 1 + sink.http.set(key, entry) +} + +const emit = (sink: Sink, call: Call, traceId: string, parentSpanId: string, startMicros: number): Emitted => { + const { rng } = sink + const spanId = rng.hex(16) + const half = (call.selfMs * 1000 * rng.spread(0.3)) / 2 + const children = call.children.reduce<{ readonly cursor: number; readonly failed?: Emitted }>( + (state, child) => { + const result = emit(sink, child, traceId, spanId, state.cursor + rng.int(50, 400)) + return { cursor: result.endMicros, failed: state.failed ?? (result.failure ? result : undefined) } + }, + { cursor: startMicros + half }, + ) + const endMicros = children.cursor + half + const durationMs = (endMicros - startMicros) / 1000 + const childFailure = children.failed + const failure: Failure | undefined = + call.failure ?? + (childFailure === undefined || call.kind === "Client" + ? undefined + : childFailure.failedService === undefined || childFailure.failedService === call.service + ? childFailure.failure + : { type: "UpstreamError", message: `${childFailure.failedService} returned 500` }) + const propagated = failure ?? childFailure?.failure + const isHttp = call.kind === "Server" || call.attributes["peer.service"] !== undefined + const status = propagated === undefined ? "200" : "500" + const resource = sink.resourceAt(call.service, startMicros) + const errorSpan = failure !== undefined + sink.traces.push({ + OrgId: sink.orgId, + Timestamp: formatMicros(startMicros), + TraceId: traceId, + SpanId: spanId, + ParentSpanId: parentSpanId, + TraceState: "", + SpanName: call.name, + SpanKind: call.kind, + ServiceName: call.service, + ResourceSchemaUrl: "", + ResourceAttributes: resource.attributes, + ScopeSchemaUrl: "", + ScopeName: "maple-seed-demo", + ScopeVersion: "1.0.0", + ScopeAttributes: {}, + Duration: Math.round((endMicros - startMicros) * 1000), + StatusCode: errorSpan ? "Error" : "Unset", + StatusMessage: failure?.message ?? "", + SpanAttributes: { + ...call.attributes, + ...(isHttp ? { "http.response.status_code": status } : undefined), + ...(errorSpan ? { "error.type": failure.type } : undefined), + }, + EventsTimestamp: errorSpan ? [formatMicros(endMicros - 100)] : [], + EventsName: errorSpan ? ["exception"] : [], + EventsAttributes: errorSpan + ? [ + { + "exception.type": failure.type, + "exception.message": failure.message, + "exception.stacktrace": `${failure.type}: ${failure.message}\n at handler (/app/${call.service}/index.js:42:13)`, + }, + ] + : [], + LinksTraceId: [], + LinksSpanId: [], + LinksTraceState: [], + LinksAttributes: [], + }) + if (call.kind === "Server" && call.http !== undefined) { + recordHttp(sink, call, startMicros, durationMs, status) + const trace = { traceId, spanId } + const route = { "http.route": call.http.route } + const ms = String(Math.round(durationMs)) + if (failure !== undefined && failure.type !== "UpstreamError") + pushLog(sink, resource, endMicros, "ERROR", `${failure.type}: ${failure.message}`, { + ...route, + "exception.type": failure.type, + "exception.message": failure.message, + }, trace) + else if (durationMs > 2000) + pushLog(sink, resource, endMicros, "WARN", `slow request ${call.http.method} ${call.http.path} took ${ms}ms`, { + ...route, + duration_ms: ms, + }, trace) + else if (rng.chance(0.55)) + pushLog(sink, resource, endMicros, "INFO", `${call.http.method} ${call.http.path} ${status} ${ms}ms`, { + ...route, + "http.response.status_code": status, + duration_ms: ms, + }, trace) + } + return { + endMicros, + failure: propagated, + failedService: call.kind === "Server" && propagated !== undefined ? call.service : childFailure?.failedService, + } +} + +export interface AcmeShopOptions { + readonly orgId: string + /** The dataset's frozen clock; the window ends here. */ + readonly now: Date + readonly hours: number + /** Traces per minute at the 19:00 UTC peak. */ + readonly peakTracesPerMinute: number + readonly seed: number +} + +const MINUTE_MICROS = 60_000_000 + +export const acmeShopTelemetry = (options: AcmeShopOptions): TelemetryRows => { + const rng = makePrng(options.seed) + const nowMicros = options.now.getTime() * 1000 + const incidentStart = nowMicros - INCIDENT_MINUTES * MINUTE_MICROS + const byName = new Map(ACME_SERVICES.map((service) => [service.name, service])) + const resourceAt = (name: string, micros: number): Resource => { + const service = byName.get(name) ?? STOREFRONT + const deployed = + service.deploy !== undefined && micros >= nowMicros - service.deploy.minutesBeforeNow * MINUTE_MICROS + return resourceFor(service, rng.int(0, PODS_PER_SERVICE - 1), deployed) + } + const sink: Sink = { orgId: options.orgId, rng, resourceAt, traces: [], logs: [], http: new Map() } + const minutes = options.hours * 60 + Array.from({ length: minutes }, (_, index) => nowMicros - (minutes - index) * MINUTE_MICROS).forEach( + (minuteStart) => { + const hour = new Date(minuteStart / 1000).getUTCHours() + const diurnal = 0.3 + 0.7 * (0.5 + 0.5 * Math.cos((2 * Math.PI * (hour - 19)) / 24)) + const count = Math.round(options.peakTracesPerMinute * diurnal * rng.spread(0.15)) + const ctx: Context = { rng, incident: minuteStart >= incidentStart } + Array.from({ length: count }).forEach(() => { + const entry = rng.weighted(ENTRY_POINTS) + emit(sink, entry(ctx), rng.hex(32), "", minuteStart + rng.int(0, MINUTE_MICROS - 1)) + }) + if (ctx.incident) + pushLog(sink, resourceAt("payment-svc", minuteStart), minuteStart + 15_000_000, "WARN", + `pool exhausted: 20/20 connections in use, ${rng.int(24, 48)} waiting`, + { "db.client.connection.pool.name": "payments" }) + }, + ) + return { + traces: sink.traces, + logs: sink.logs, + ...metricRows(options, rng, sink.http, incidentStart), + } +} + +const FIVE_MINUTES_MICROS = 5 * MINUTE_MICROS +const MEMORY_BASE_MB = { go: 48, python: 160, nodejs: 210 } as const + +const metricBase = (orgId: string, resource: Resource, name: string, unit: string, micros: number) => ({ + OrgId: orgId, + ResourceAttributes: resource.attributes, + ResourceSchemaUrl: "", + ScopeName: "maple-seed-demo", + ScopeVersion: "1.0.0", + ScopeAttributes: {}, + ScopeSchemaUrl: "", + ServiceName: resource.service.name, + MetricName: name, + MetricDescription: "", + MetricUnit: unit, + StartTimeUnix: formatMicros(micros - FIVE_MINUTES_MICROS), + TimeUnix: formatMicros(micros), + ExemplarsTraceId: [], + ExemplarsSpanId: [], + ExemplarsTimestamp: [], + ExemplarsValue: [], + ExemplarsFilteredAttributes: [], +}) + +/** Per pod every five minutes: CPU and memory gauges, the payments pool, orders created, request durations. */ +const metricRows = (options: AcmeShopOptions, rng: Prng, http: ReadonlyMap, incidentStart: number) => { + const nowMicros = options.now.getTime() * 1000 + const slots = (options.hours * 60) / 5 + const times = Array.from({ length: slots }, (_, index) => nowMicros - (slots - 1 - index) * FIVE_MINUTES_MICROS) + const pods = ACME_SERVICES.flatMap((service) => + Array.from({ length: PODS_PER_SERVICE }, (_, pod) => ({ service, pod })), + ) + const deployedAt = (service: ServiceSpec, micros: number) => + service.deploy !== undefined && micros >= nowMicros - service.deploy.minutesBeforeNow * MINUTE_MICROS + const gauge = times.flatMap((micros) => + pods.flatMap(({ service, pod }) => { + const resource = resourceFor(service, pod, deployedAt(service, micros)) + const load = rng.next() + const point = (name: string, unit: string, value: number, attributes: Record = {}) => ({ + ...metricBase(options.orgId, resource, name, unit, micros), + Attributes: attributes, + Value: value, + Flags: 0, + }) + const pool = + service.name === "payment-svc" + ? [ + point("db.client.connection.count", "{connection}", micros >= incidentStart ? 20 : rng.int(2, 8), { + "db.client.connection.pool.name": "payments", + "db.client.connection.state": "used", + }), + ] + : [] + return [ + point("process.cpu.utilization", "1", Math.min(0.95, 0.04 + load * 0.3)), + point("process.memory.usage", "By", (MEMORY_BASE_MB[service.language] + load * 40) * 1024 * 1024), + ...pool, + ] + }), + ) + const sum = times.map((micros) => ({ + ...metricBase(options.orgId, resourceFor(CHECKOUT, 0, false), "shop.orders.created", "{order}", micros), + Attributes: {}, + Value: rng.int(4, 30), + Flags: 0, + AggregationTemporality: 1, + IsMonotonic: true, + })) + const histogram = [...http.entries()].map(([key, entry]) => { + const [service, route, method, status, bucket] = key.split("|") + const spec = ACME_SERVICES.find((candidate) => candidate.name === service) ?? STOREFRONT + const micros = Number(bucket) + FIVE_MINUTES_MICROS + return { + ...metricBase(options.orgId, resourceFor(spec, 0, deployedAt(spec, micros)), "http.server.request.duration", "s", micros), + Attributes: { "http.request.method": method, "http.route": route, "http.response.status_code": status }, + Count: entry.count, + Sum: entry.sum, + BucketCounts: entry.buckets, + ExplicitBounds: HTTP_BOUNDS, + Flags: 0, + Min: entry.min, + Max: entry.max, + AggregationTemporality: 1, + } + }) + return { metrics_gauge: gauge, metrics_sum: sum, metrics_histogram: histogram } +} diff --git a/packages/ui-parity/src/fixtures/telemetry/rows.ts b/packages/ui-parity/src/fixtures/telemetry/rows.ts new file mode 100644 index 0000000000..358ca8e499 --- /dev/null +++ b/packages/ui-parity/src/fixtures/telemetry/rows.ts @@ -0,0 +1,29 @@ +/** A row in the shape `INSERT ... FORMAT JSONEachRow` takes: maps are objects, DateTime64 is text. */ +export type WarehouseRow = Readonly> + +/** Raw tables the seed writes; every rollup is filled by the schema's materialized views. */ +export const TELEMETRY_TABLES = ["traces", "logs", "metrics_gauge", "metrics_sum", "metrics_histogram"] as const +export type TelemetryTable = (typeof TELEMETRY_TABLES)[number] + +export type TelemetryRows = Readonly>> + +export const emptyTelemetry = { + traces: [], + logs: [], + metrics_gauge: [], + metrics_sum: [], + metrics_histogram: [], +} satisfies TelemetryRows + +const pad = (value: number, width: number) => String(value).padStart(width, "0") + +/** Epoch microseconds as a ClickHouse `DateTime64(9)` literal, always UTC. */ +export const formatMicros = (micros: number): string => { + const date = new Date(Math.floor(micros / 1000)) + const fraction = pad(Math.floor(micros % 1_000_000), 6) + return `${date.toISOString().slice(0, 19).replace("T", " ")}.${fraction}000` +} + +/** Epoch microseconds as a ClickHouse `DateTime` literal (second precision). */ +export const formatSeconds = (micros: number): string => + new Date(Math.floor(micros / 1000)).toISOString().slice(0, 19).replace("T", " ") From be38f78bba39d04a76f658a7768497ab0cbc7ec2 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 16:30:30 +0200 Subject: [PATCH 005/629] feat(web-foldkit): S1 skeleton, sign-in at pixel parity --- apps/web-foldkit/index.html | 521 +++++++++++ apps/web-foldkit/src/command.ts | 68 ++ apps/web-foldkit/src/entry.ts | 23 + apps/web-foldkit/src/layout/account-layout.ts | 56 ++ apps/web-foldkit/src/layout/boot-splash.ts | 58 ++ apps/web-foldkit/src/main.ts | 52 ++ apps/web-foldkit/src/message.ts | 33 + apps/web-foldkit/src/model.ts | 32 + apps/web-foldkit/src/page/clerk-sign-in.ts | 33 + apps/web-foldkit/src/page/home/home.ts | 85 ++ apps/web-foldkit/src/page/home/index.ts | 1 + apps/web-foldkit/src/page/sign-in/index.ts | 2 + .../src/page/sign-in/sign-in.scene.test.ts | 84 ++ apps/web-foldkit/src/page/sign-in/sign-in.ts | 117 +++ apps/web-foldkit/src/page/sign-in/view.ts | 92 ++ apps/web-foldkit/src/platform/api-clients.ts | 81 ++ apps/web-foldkit/src/platform/auth-api.ts | 84 ++ apps/web-foldkit/src/platform/clerk.ts | 101 +++ .../src/platform/session-bearer.ts | 54 ++ apps/web-foldkit/src/route/app-route.ts | 101 +++ apps/web-foldkit/src/route/index.ts | 3 + apps/web-foldkit/src/route/redirect.ts | 55 ++ apps/web-foldkit/src/route/route.test.ts | 44 + apps/web-foldkit/src/route/routers.ts | 268 ++++++ apps/web-foldkit/src/route/search.ts | 821 ++++++++++++++++++ apps/web-foldkit/src/route/segment.ts | 25 + apps/web-foldkit/src/route/tanstack-search.ts | 45 + apps/web-foldkit/src/styles.css | 8 + apps/web-foldkit/src/subscription.ts | 40 + apps/web-foldkit/src/ui/button.ts | 85 ++ apps/web-foldkit/src/ui/field.ts | 102 +++ apps/web-foldkit/src/ui/icons.ts | 80 ++ apps/web-foldkit/src/update.ts | 188 ++++ apps/web-foldkit/src/view.ts | 75 ++ apps/web-foldkit/src/vitest-setup.ts | 3 + apps/web-foldkit/tsconfig.json | 30 + apps/web-foldkit/vite.config.ts | 61 ++ apps/web-foldkit/vitest.config.ts | 12 + docs/foldkit-decisions/s1-skeleton.md | 110 +++ 39 files changed, 3733 insertions(+) create mode 100644 apps/web-foldkit/index.html create mode 100644 apps/web-foldkit/src/command.ts create mode 100644 apps/web-foldkit/src/entry.ts create mode 100644 apps/web-foldkit/src/layout/account-layout.ts create mode 100644 apps/web-foldkit/src/layout/boot-splash.ts create mode 100644 apps/web-foldkit/src/main.ts create mode 100644 apps/web-foldkit/src/message.ts create mode 100644 apps/web-foldkit/src/model.ts create mode 100644 apps/web-foldkit/src/page/clerk-sign-in.ts create mode 100644 apps/web-foldkit/src/page/home/home.ts create mode 100644 apps/web-foldkit/src/page/home/index.ts create mode 100644 apps/web-foldkit/src/page/sign-in/index.ts create mode 100644 apps/web-foldkit/src/page/sign-in/sign-in.scene.test.ts create mode 100644 apps/web-foldkit/src/page/sign-in/sign-in.ts create mode 100644 apps/web-foldkit/src/page/sign-in/view.ts create mode 100644 apps/web-foldkit/src/platform/api-clients.ts create mode 100644 apps/web-foldkit/src/platform/auth-api.ts create mode 100644 apps/web-foldkit/src/platform/clerk.ts create mode 100644 apps/web-foldkit/src/platform/session-bearer.ts create mode 100644 apps/web-foldkit/src/route/app-route.ts create mode 100644 apps/web-foldkit/src/route/index.ts create mode 100644 apps/web-foldkit/src/route/redirect.ts create mode 100644 apps/web-foldkit/src/route/route.test.ts create mode 100644 apps/web-foldkit/src/route/routers.ts create mode 100644 apps/web-foldkit/src/route/search.ts create mode 100644 apps/web-foldkit/src/route/segment.ts create mode 100644 apps/web-foldkit/src/route/tanstack-search.ts create mode 100644 apps/web-foldkit/src/styles.css create mode 100644 apps/web-foldkit/src/subscription.ts create mode 100644 apps/web-foldkit/src/ui/button.ts create mode 100644 apps/web-foldkit/src/ui/field.ts create mode 100644 apps/web-foldkit/src/ui/icons.ts create mode 100644 apps/web-foldkit/src/update.ts create mode 100644 apps/web-foldkit/src/view.ts create mode 100644 apps/web-foldkit/src/vitest-setup.ts create mode 100644 apps/web-foldkit/tsconfig.json create mode 100644 apps/web-foldkit/vite.config.ts create mode 100644 apps/web-foldkit/vitest.config.ts create mode 100644 docs/foldkit-decisions/s1-skeleton.md diff --git a/apps/web-foldkit/index.html b/apps/web-foldkit/index.html new file mode 100644 index 0000000000..3a720c149f --- /dev/null +++ b/apps/web-foldkit/index.html @@ -0,0 +1,521 @@ + + + + + + Maple + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+ +

Collecting spans…

+
+
+ + + diff --git a/apps/web-foldkit/src/command.ts b/apps/web-foldkit/src/command.ts new file mode 100644 index 0000000000..0eb3289af0 --- /dev/null +++ b/apps/web-foldkit/src/command.ts @@ -0,0 +1,68 @@ +import { Duration, Effect, Option, Schema } from "effect" +import { Command } from "foldkit" +import { load, pushUrl, replaceUrl } from "foldkit/navigation" +import { Message } from "./message" +import { clearSessionToken, MapleAuthApi, readSessionToken, saveSessionToken } from "./platform/auth-api" +import { loadClerk } from "./platform/clerk" + +/** How long a signed-out Clerk answer may still turn into a session on a hard refresh (main.tsx). */ +const AUTH_SETTLE_TIMEOUT = Duration.seconds(2) + +export const NavigateInternal = Command.define("NavigateInternal", { + args: { url: Schema.String }, + messages: [Message.CompletedNavigateInternal], + execute: ({ url }) => pushUrl(url).pipe(Effect.as(Message.CompletedNavigateInternal())), +}) + +export const ReplaceUrl = Command.define("ReplaceUrl", { + args: { url: Schema.String }, + messages: [Message.CompletedReplaceUrl], + execute: ({ url }) => replaceUrl(url).pipe(Effect.as(Message.CompletedReplaceUrl())), +}) + +export const LoadExternal = Command.define("LoadExternal", { + args: { href: Schema.String }, + messages: [Message.CompletedLoadExternal], + execute: ({ href }) => load(href).pipe(Effect.as(Message.CompletedLoadExternal())), +}) + +/** `resolveSelfHostedRouterAuth`: a rejected token is cleared, an unreachable API keeps it. */ +export const CheckSession = Command.define("CheckSession", { + messages: [Message.SucceededCheckSession, Message.FailedCheckSession], + execute: Effect.gen(function* () { + const maybeToken = yield* readSessionToken + if (Option.isNone(maybeToken)) { + return Message.FailedCheckSession({ failure: "NoToken" }) + } + const auth = yield* MapleAuthApi + const tenant = yield* auth.session(maybeToken.value) + return Message.SucceededCheckSession({ orgId: tenant.orgId }) + }).pipe( + Effect.catchTag("@maple/web-foldkit/SessionCheckError", (error) => + error.reason === "Rejected" + ? Effect.as(clearSessionToken, Message.FailedCheckSession({ failure: "Rejected" })) + : Effect.succeed(Message.FailedCheckSession({ failure: "Unreachable" })), + ), + ), +}) + +export const SaveSessionToken = Command.define("SaveSessionToken", { + args: { token: Schema.String }, + messages: [Message.CompletedSaveSessionToken], + execute: ({ token }) => saveSessionToken(token).pipe(Effect.as(Message.CompletedSaveSessionToken())), +}) + +export const LoadClerk = Command.define("LoadClerk", { + messages: [Message.CompletedLoadClerk, Message.FailedLoadClerk], + execute: loadClerk.pipe( + Effect.map((snapshot) => Message.CompletedLoadClerk({ snapshot })), + Effect.catchTag("@maple/web-foldkit/ClerkLoadError", (error) => + Effect.succeed(Message.FailedLoadClerk({ message: error.message })), + ), + ), +}) + +export const WaitForAuthSettle = Command.define("WaitForAuthSettle", { + messages: [Message.CompletedWaitForAuthSettle], + execute: Effect.sleep(AUTH_SETTLE_TIMEOUT).pipe(Effect.as(Message.CompletedWaitForAuthSettle())), +}) diff --git a/apps/web-foldkit/src/entry.ts b/apps/web-foldkit/src/entry.ts new file mode 100644 index 0000000000..8462242739 --- /dev/null +++ b/apps/web-foldkit/src/entry.ts @@ -0,0 +1,23 @@ +import "./styles.css" + +import { Runtime } from "foldkit" +import { Flags, flags, init, Message, Model, subscriptions, update, view } from "./main" +import { ResourcesLive } from "./platform/api-clients" + +const application = Runtime.makeApplication({ + Model, + Flags, + init, + update, + view, + subscriptions, + resources: ResourcesLive, + container: document.getElementById("app") ?? document.body, + routing: { + onUrlRequest: (request) => Message.ClickedLink({ request }), + onUrlChange: (url) => Message.ChangedUrl({ url }), + }, + devTools: { Message }, +}) + +Runtime.run(application, { flags }) diff --git a/apps/web-foldkit/src/layout/account-layout.ts b/apps/web-foldkit/src/layout/account-layout.ts new file mode 100644 index 0000000000..0d33d6b69f --- /dev/null +++ b/apps/web-foldkit/src/layout/account-layout.ts @@ -0,0 +1,56 @@ +import type { Html, HtmlBuilder } from "foldkit/html" +import { mapleMark } from "../ui/icons" + +const WEBSITE_URL = "https://maple.dev" +const DOCS_URL = "https://maple.dev/docs" + +/** `` (apps/web/src/components/layout/account-layout.tsx): artwork left, form right. */ +export const accountLayout = (h: HtmlBuilder, children: ReadonlyArray): Html => + h.main( + [h.Class("account-layout dark")], + [ + h.aside( + [h.Class("account-art")], + [ + h.img([ + h.Src("/art/maple-auth-arch.webp"), + h.Alt(""), + h.Class("account-art-image"), + h.Fetchpriority("high"), + ]), + h.a( + [h.Class("account-wordmark"), h.Href(WEBSITE_URL), h.AriaLabel("Maple home")], + [mapleMark(h, 30), h.span([], ["Maple"])], + ), + h.div( + [h.Class("account-art-copy")], + [ + h.p([h.Class("account-eyebrow")], ["Open-source observability"]), + h.p([h.Class("account-art-headline")], ["Understand", h.br([]), "what’s happening."]), + h.p([], ["Traces, logs, and metrics.", h.br([]), "One place to see the whole picture."]), + ], + ), + ], + ), + h.section( + [h.Class("account-content"), h.AriaLabel("Your Maple account")], + [ + h.header( + [h.Class("account-header")], + [ + h.a( + [h.Class("account-mobile-wordmark"), h.Href(WEBSITE_URL), h.AriaLabel("Maple home")], + [mapleMark(h, 26), h.span([], ["Maple"])], + ), + h.a([h.Class("account-back"), h.Href(WEBSITE_URL)], ["Back to website"]), + ], + ), + h.div([h.Class("account-form")], children), + h.footer( + [h.Class("account-footer")], + [h.span([], ["Built on OpenTelemetry."]), h.a([h.Href(DOCS_URL)], ["Documentation"])], + ), + ], + ), + ], + ) diff --git a/apps/web-foldkit/src/layout/boot-splash.ts b/apps/web-foldkit/src/layout/boot-splash.ts new file mode 100644 index 0000000000..391d4281a3 --- /dev/null +++ b/apps/web-foldkit/src/layout/boot-splash.ts @@ -0,0 +1,58 @@ +import type { Html, HtmlBuilder } from "foldkit/html" +import { MAPLE_MARK_PATH } from "../ui/icons" + +const TRACKS = [1, 2, 3, 4, 5] + +/** `` (apps/web/src/components/boot-splash.tsx): shown while the session resolves. */ +export const bootSplash = (h: HtmlBuilder): Html => + h.main( + [ + h.Role("status"), + h.AriaLabel("Loading Maple"), + h.Class("flex min-h-screen w-full flex-col items-center justify-center gap-6 bg-background"), + ], + [ + h.div( + [h.Class("boot-mark")], + [ + h.svg( + [ + h.Attribute("viewBox", "0 0 739 739"), + h.Attribute("width", "56"), + h.Attribute("height", "56"), + h.AriaHidden(true), + ], + [ + h.path([ + h.Attribute("fill-rule", "evenodd"), + h.Attribute("fill", "currentColor"), + h.Attribute("d", MAPLE_MARK_PATH), + ]), + lid(h, "119", "255"), + lid(h, "284", "275"), + ], + ), + ], + ), + h.div( + [h.Class("boot-trace"), h.AriaHidden(true)], + [ + ...TRACKS.map((index) => h.span([h.Class(`boot-track boot-track--${index}`)])), + ...TRACKS.map((index) => h.span([h.Class(`boot-span boot-span--${index}`)])), + h.span([h.Class("boot-scan")]), + ], + ), + h.p([h.Class("boot-caption")], ["Collecting spans…"]), + h.span([h.Class("sr-only")], ["Loading…"]), + ], + ) + +const lid = (h: HtmlBuilder, x: string, y: string): Html => + h.rect([ + h.Class("boot-lid"), + h.Attribute("x", x), + h.Attribute("y", y), + h.Attribute("width", "142"), + h.Attribute("height", "222"), + h.Attribute("fill", "currentColor"), + ]) diff --git a/apps/web-foldkit/src/main.ts b/apps/web-foldkit/src/main.ts new file mode 100644 index 0000000000..1da677b7d2 --- /dev/null +++ b/apps/web-foldkit/src/main.ts @@ -0,0 +1,52 @@ +import { isClerkAuthEnabled } from "@maple/web/lib/services/common/auth-mode" +import { Effect, Option, Schema } from "effect" +import type { Runtime } from "foldkit" +import type { Url } from "foldkit/url" +import { CheckSession, LoadClerk } from "./command" +import type { Message } from "./message" +import { ClerkPhase, type Model, Session } from "./model" +import * as Home from "./page/home" +import * as SignIn from "./page/sign-in" +import type { Resources } from "./platform/api-clients" +import { readSessionToken } from "./platform/auth-api" +import { urlToAppRoute } from "./route" +import { settle } from "./update" + +export { Message } from "./message" +export { Model } from "./model" +export { subscriptions } from "./subscription" +export { update } from "./update" +export { view } from "./view" + +// FLAGS + +/** Read before the first render, so a signed-out visit paints the page, not the boot splash. */ +export const Flags = Schema.Struct({ hasStoredSession: Schema.Boolean }) +export type Flags = typeof Flags.Type + +export const flags: Effect.Effect = Effect.map(readSessionToken, (maybeToken) => ({ + hasStoredSession: Option.isSome(maybeToken), +})) + +// INIT + +export const init: Runtime.RoutingApplicationInit = (flags, url: Url) => { + const route = urlToAppRoute(url) + const base = { route, signIn: SignIn.init(), home: Home.init() } + + if (isClerkAuthEnabled) { + return { + model: { ...base, session: Session.Resolving(), clerk: ClerkPhase.Loading() }, + commands: [LoadClerk()], + } + } + + if (flags.hasStoredSession) { + return { + model: { ...base, session: Session.Resolving(), clerk: ClerkPhase.Unused() }, + commands: [CheckSession()], + } + } + + return settle({ ...base, session: Session.SignedOut(), clerk: ClerkPhase.Unused() }) +} diff --git a/apps/web-foldkit/src/message.ts b/apps/web-foldkit/src/message.ts new file mode 100644 index 0000000000..197f3f7090 --- /dev/null +++ b/apps/web-foldkit/src/message.ts @@ -0,0 +1,33 @@ +import { OrgId } from "@maple/domain" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { UrlRequest } from "foldkit/navigation" +import { Url } from "foldkit/url" +import * as Home from "./page/home" +import * as SignIn from "./page/sign-in" +import { ClerkSnapshot } from "./platform/clerk" + +/** Why the stored self-hosted session did not resolve to a signed-in tenant. */ +export const SessionCheckFailure = Schema.Literals(["NoToken", "Rejected", "Unreachable"]) +export type SessionCheckFailure = typeof SessionCheckFailure.Type + +export const Message = defineMessageUnion({ + ClickedLink: { request: UrlRequest }, + ChangedUrl: { url: Url }, + CompletedNavigateInternal: {}, + CompletedReplaceUrl: {}, + CompletedLoadExternal: {}, + SucceededCheckSession: { orgId: OrgId }, + FailedCheckSession: { failure: SessionCheckFailure }, + ChangedSelfHostedSession: {}, + CompletedSaveSessionToken: {}, + CompletedLoadClerk: { snapshot: ClerkSnapshot }, + FailedLoadClerk: { message: Schema.String }, + ChangedClerkSession: { snapshot: ClerkSnapshot }, + CompletedWaitForAuthSettle: {}, + CompletedMountClerkSignIn: {}, + FailedMountClerkSignIn: { message: Schema.String }, + GotSignInMessage: { message: SignIn.Message }, + GotHomeMessage: { message: Home.Message }, +}) +export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/model.ts b/apps/web-foldkit/src/model.ts new file mode 100644 index 0000000000..54e9d1be16 --- /dev/null +++ b/apps/web-foldkit/src/model.ts @@ -0,0 +1,32 @@ +import { OrgId } from "@maple/domain" +import { Schema } from "effect" +import { defineTaggedUnion } from "foldkit/schema" +import * as Home from "./page/home" +import * as SignIn from "./page/sign-in" +import { AppRoute } from "./route" + +/** The router's `auth` context in apps/web: unknown until resolved (``), then settled. */ +export const Session = defineTaggedUnion({ + Resolving: {}, + SignedOut: {}, + SignedIn: { maybeOrgId: Schema.Option(OrgId) }, +}) +export type Session = typeof Session.Type + +/** clerk-js on `window.Clerk`, in Clerk mode only. */ +export const ClerkPhase = defineTaggedUnion({ + Unused: {}, + Loading: {}, + Loaded: {}, + Failed: { message: Schema.String }, +}) +export type ClerkPhase = typeof ClerkPhase.Type + +export const Model = Schema.Struct({ + route: AppRoute, + session: Session, + clerk: ClerkPhase, + signIn: SignIn.Model, + home: Home.Model, +}) +export type Model = typeof Model.Type diff --git a/apps/web-foldkit/src/page/clerk-sign-in.ts b/apps/web-foldkit/src/page/clerk-sign-in.ts new file mode 100644 index 0000000000..c8f26c9ecc --- /dev/null +++ b/apps/web-foldkit/src/page/clerk-sign-in.ts @@ -0,0 +1,33 @@ +import { Effect, Option, Schema } from "effect" +import { Mount } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { accountLayout } from "../layout/account-layout" +import { Message } from "../message" + +/** `` from @clerk/clerk-react, mounted by clerk-js into this element. */ +const MountClerkSignIn = Mount.define("MountClerkSignIn", { + args: { maybeRedirectUrl: Schema.Option(Schema.String) }, + messages: [Message.CompletedMountClerkSignIn, Message.FailedMountClerkSignIn], + execute: ({ element, maybeRedirectUrl }) => + Option.match(Option.fromNullishOr(window.Clerk), { + onNone: () => Effect.succeed(Message.FailedMountClerkSignIn({ message: "Clerk is not loaded" })), + onSome: (clerk) => + Effect.acquireRelease( + Effect.sync(() => { + if (element instanceof HTMLDivElement) { + clerk.mountSignIn(element, { forceRedirectUrl: Option.getOrUndefined(maybeRedirectUrl) }) + } + }), + () => + Effect.sync(() => { + if (element instanceof HTMLDivElement) { + clerk.unmountSignIn(element) + } + }), + ).pipe(Effect.as(Message.CompletedMountClerkSignIn())), + }), +}) + +/** The Clerk-mode `/sign-in`. The PR-preview access form is not ported yet. */ +export const view = (h: HtmlBuilder, maybeRedirectUrl: Option.Option): Html => + accountLayout(h, [h.div([h.OnMount(MountClerkSignIn({ maybeRedirectUrl }))])]) diff --git a/apps/web-foldkit/src/page/home/home.ts b/apps/web-foldkit/src/page/home/home.ts new file mode 100644 index 0000000000..8bb596adb3 --- /dev/null +++ b/apps/web-foldkit/src/page/home/home.ts @@ -0,0 +1,85 @@ +import { CloudflareIntegrationStatus } from "@maple/domain/http" +import { Effect, Schema } from "effect" +import { AsyncData, Command, Submodel, type Update } from "foldkit" +import { defineMessageUnion } from "foldkit/message" +import { MapleInternalApiClient } from "../../platform/api-clients" + +// MODEL + +/** Why the shell's boot request failed. */ +export const LoadFailure = Schema.Literals(["Unavailable", "Unreachable"]) +export type LoadFailure = typeof LoadFailure.Type + +const CloudflareStatusData = AsyncData.Schema(CloudflareIntegrationStatus, LoadFailure) + +/** S1 placeholder for `/`: proves the session reaches the API through one boot request. */ +export const Model = Schema.Struct({ cloudflareStatus: CloudflareStatusData.schema }) +export type Model = typeof Model.Type + +export const init = (): Model => ({ cloudflareStatus: CloudflareStatusData.Idle() }) + +// MESSAGE + +export const Message = defineMessageUnion({ + SucceededFetchCloudflareStatus: { status: CloudflareIntegrationStatus }, + FailedFetchCloudflareStatus: { failure: LoadFailure }, +}) +export type Message = typeof Message.Type + +// COMMAND + +/** `integrations.cloudflareStatus`: the sidebar asks for it on every boot (`useInfraSurfaces`). */ +export const FetchCloudflareStatus = Command.define("FetchCloudflareStatus", { + messages: [Message.SucceededFetchCloudflareStatus, Message.FailedFetchCloudflareStatus], + execute: Effect.gen(function* () { + const client = yield* MapleInternalApiClient + const status = yield* client.integrations.cloudflareStatus() + return Message.SucceededFetchCloudflareStatus({ status }) + }).pipe( + Effect.catchTag("HttpClientError", () => + Effect.succeed(Message.FailedFetchCloudflareStatus({ failure: "Unreachable" })), + ), + Effect.catch(() => Effect.succeed(Message.FailedFetchCloudflareStatus({ failure: "Unavailable" }))), + ), +}) + +// UPDATE + +type UpdateReturn = Update.Return + +/** Entering `/` signed in: load unless a load already ran. */ +export const load = (model: Model): UpdateReturn => + AsyncData.match(model.cloudflareStatus, { + onIdle: () => ({ model: { cloudflareStatus: CloudflareStatusData.Loading() }, commands: [FetchCloudflareStatus()] }), + onLoading: () => ({ model }), + onRefreshing: () => ({ model }), + onFailure: () => ({ model: { cloudflareStatus: CloudflareStatusData.Loading() }, commands: [FetchCloudflareStatus()] }), + onStale: () => ({ model }), + onSuccess: () => ({ model }), + }) + +export const update = (_model: Model, message: Message) => + Message.match(message, { + SucceededFetchCloudflareStatus: ({ status }) => ({ + model: { cloudflareStatus: CloudflareStatusData.Success({ data: status }) }, + }), + FailedFetchCloudflareStatus: ({ failure }) => ({ + model: { cloudflareStatus: CloudflareStatusData.Failure({ error: failure }) }, + }), + }) + +// VIEW + +const statusText = (model: Model): string => + AsyncData.match(model.cloudflareStatus, { + onIdle: () => "Waiting for the session", + onLoading: () => "Loading Cloudflare integration status", + onRefreshing: () => "Refreshing Cloudflare integration status", + onFailure: (failure) => `Cloudflare integration status failed: ${failure}`, + onStale: ({ error }) => `Cloudflare integration status is stale: ${error}`, + onSuccess: (status) => `Cloudflare integration: ${status.connected ? "connected" : "not connected"}`, + }) + +export const view = Submodel.defineView((model, h) => + h.p([h.Class("text-muted-foreground text-sm"), h.DataAttribute("testid", "boot-request")], [statusText(model)]), +) diff --git a/apps/web-foldkit/src/page/home/index.ts b/apps/web-foldkit/src/page/home/index.ts new file mode 100644 index 0000000000..133e891604 --- /dev/null +++ b/apps/web-foldkit/src/page/home/index.ts @@ -0,0 +1 @@ +export { FetchCloudflareStatus, init, load, LoadFailure, Message, Model, update, view } from "./home" diff --git a/apps/web-foldkit/src/page/sign-in/index.ts b/apps/web-foldkit/src/page/sign-in/index.ts new file mode 100644 index 0000000000..890ed05966 --- /dev/null +++ b/apps/web-foldkit/src/page/sign-in/index.ts @@ -0,0 +1,2 @@ +export { init, Message, Model, OutMessage, SignInFailure, SignInWithRootPassword, Submission, update } from "./sign-in" +export { view } from "./view" diff --git a/apps/web-foldkit/src/page/sign-in/sign-in.scene.test.ts b/apps/web-foldkit/src/page/sign-in/sign-in.scene.test.ts new file mode 100644 index 0000000000..9ee8d3e952 --- /dev/null +++ b/apps/web-foldkit/src/page/sign-in/sign-in.scene.test.ts @@ -0,0 +1,84 @@ +import { + Command, + expect, + expectNoOutMessage, + expectOutMessage, + given, + label, + role, + scene, + submit, + text, + type, + within, +} from "foldkit/scene" +import { describe, test } from "vitest" +import { init, Message, OutMessage, SignInFailure, SignInWithRootPassword, update, view } from "./index" + +const passwordField = label("Root password") +const signInButton = role("button", { name: "Sign in" }) +const form = role("form") + +describe("self-hosted sign-in", () => { + test("typing the password fills the field and marks it dirty", () => { + scene( + { update, view }, + given(init()), + expect(passwordField).toHaveValue(""), + type(passwordField, "hunter2"), + expect(passwordField).toHaveValue("hunter2"), + expect(passwordField).toHaveAttr("data-filled", ""), + expect(passwordField).toHaveAttr("data-dirty", ""), + expect(passwordField).not.toHaveAttr("aria-invalid"), + ) + }) + + test("a successful sign-in shows the loading button, then hands the token to the root", () => { + scene( + { update, view }, + given(init()), + type(passwordField, "correct-horse"), + submit(form), + Command.expectExact(SignInWithRootPassword({ password: "correct-horse" })), + expect(signInButton).toBeDisabled(), + expect(signInButton).toHaveAttr("data-loading", ""), + expect(passwordField).toBeDisabled(), + Command.resolve(SignInWithRootPassword, Message.SucceededSignInWithRootPassword({ token: "session-token" })), + expectOutMessage(OutMessage.SucceededSignIn({ token: "session-token" })), + ) + }) + + test("a rejected password shows the API's message and keeps the form usable", () => { + scene( + { update, view }, + given(init()), + type(passwordField, "wrong"), + submit(form), + Command.resolve( + SignInWithRootPassword, + Message.FailedSignInWithRootPassword({ + failure: SignInFailure.Rejected({ message: "Invalid root password" }), + }), + ), + expectNoOutMessage(), + expect(within(form, role("alert"))).toHaveText("Invalid root password"), + expect(passwordField).toHaveAttr("aria-invalid", "true"), + expect(signInButton).toBeEnabled(), + expect(passwordField).toBeEnabled(), + ) + }) + + test("an unreachable API shows the browser's fetch failure, like apps/web", () => { + scene( + { update, view }, + given(init()), + type(passwordField, "anything"), + submit(form), + Command.resolve( + SignInWithRootPassword, + Message.FailedSignInWithRootPassword({ failure: SignInFailure.Unreachable() }), + ), + expect(text("Failed to fetch")).toExist(), + ) + }) +}) diff --git a/apps/web-foldkit/src/page/sign-in/sign-in.ts b/apps/web-foldkit/src/page/sign-in/sign-in.ts new file mode 100644 index 0000000000..1ba8f76fbe --- /dev/null +++ b/apps/web-foldkit/src/page/sign-in/sign-in.ts @@ -0,0 +1,117 @@ +import { Effect, Schema } from "effect" +import { Command, type Update } from "foldkit" +import { defineMessageUnion } from "foldkit/message" +import { defineTaggedUnion } from "foldkit/schema" +import { modifyFields } from "foldkit/struct" +import { MapleAuthApi } from "../../platform/auth-api" + +// MODEL + +/** Why the root password was not accepted; the view turns it into apps/web's copy. */ +export const SignInFailure = defineTaggedUnion({ + Rejected: { message: Schema.String }, + Unreachable: {}, + Unexpected: {}, +}) +export type SignInFailure = typeof SignInFailure.Type + +export const Submission = defineTaggedUnion({ + NotSubmitted: {}, + Submitting: {}, + Failed: { failure: SignInFailure }, +}) +export type Submission = typeof Submission.Type + +export const Model = Schema.Struct({ + password: Schema.String, + /** Base UI field state: focused now, and blurred at least once. */ + isPasswordFocused: Schema.Boolean, + isPasswordTouched: Schema.Boolean, + submission: Submission, +}) +export type Model = typeof Model.Type + +export const init = (): Model => ({ + password: "", + isPasswordFocused: false, + isPasswordTouched: false, + submission: Submission.NotSubmitted(), +}) + +// MESSAGE + +export const Message = defineMessageUnion({ + UpdatedPassword: { value: Schema.String }, + FocusedPassword: {}, + BlurredPassword: {}, + SubmittedSignIn: {}, + SucceededSignInWithRootPassword: { token: Schema.String }, + FailedSignInWithRootPassword: { failure: SignInFailure }, +}) +export type Message = typeof Message.Type + +export const OutMessage = defineMessageUnion({ + SucceededSignIn: { token: Schema.String }, +}) +export type OutMessage = typeof OutMessage.Type + +// COMMAND + +const rejected = (error: { readonly message: string }) => + Effect.succeed(Message.FailedSignInWithRootPassword({ failure: SignInFailure.Rejected({ message: error.message }) })) + +/** `POST /api/auth/login` with the root password (`loginSelfHosted` in apps/web's sign-in route). */ +export const SignInWithRootPassword = Command.define("SignInWithRootPassword", { + args: { password: Schema.String }, + messages: [Message.SucceededSignInWithRootPassword, Message.FailedSignInWithRootPassword], + execute: ({ password }) => + Effect.gen(function* () { + const auth = yield* MapleAuthApi + const session = yield* auth.login(password) + return Message.SucceededSignInWithRootPassword({ token: session.token }) + }).pipe( + Effect.catchTags({ + "@maple/http/errors/SelfHostedInvalidPasswordError": rejected, + "@maple/http/errors/SelfHostedAuthDisabledError": rejected, + HttpClientError: (error) => + Effect.succeed( + Message.FailedSignInWithRootPassword({ + failure: + error.reason._tag === "TransportError" ? SignInFailure.Unreachable() : SignInFailure.Unexpected(), + }), + ), + }), + Effect.catch(() => Effect.succeed(Message.FailedSignInWithRootPassword({ failure: SignInFailure.Unexpected() }))), + ), +}) + +// UPDATE + +type UpdateReturn = Update.ReturnWithOutMessage + +export const update = (model: Model, message: Message) => + Message.match(message, { + UpdatedPassword: ({ value }) => ({ model: modifyFields(model, { password: () => value }) }), + FocusedPassword: () => ({ model: modifyFields(model, { isPasswordFocused: () => true }) }), + BlurredPassword: () => ({ + model: modifyFields(model, { isPasswordFocused: () => false, isPasswordTouched: () => true }), + }), + SubmittedSignIn: () => + Submission.match(model.submission, { + Submitting: () => ({ model }), + NotSubmitted: () => submit(model), + Failed: () => submit(model), + }), + SucceededSignInWithRootPassword: ({ token }) => ({ + model, + outMessage: OutMessage.SucceededSignIn({ token }), + }), + FailedSignInWithRootPassword: ({ failure }) => ({ + model: modifyFields(model, { submission: () => Submission.Failed({ failure }) }), + }), + }) + +const submit = (model: Model): UpdateReturn => ({ + model: modifyFields(model, { submission: () => Submission.Submitting() }), + commands: [SignInWithRootPassword({ password: model.password })], +}) diff --git a/apps/web-foldkit/src/page/sign-in/view.ts b/apps/web-foldkit/src/page/sign-in/view.ts new file mode 100644 index 0000000000..7fe3b6aa75 --- /dev/null +++ b/apps/web-foldkit/src/page/sign-in/view.ts @@ -0,0 +1,92 @@ +import { Option } from "effect" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { accountLayout } from "../../layout/account-layout" +import { button } from "../../ui/button" +import { field, fieldError, fieldLabel, input, type FieldIds, type FieldState } from "../../ui/field" +import { Message, type Model, SignInFailure, Submission } from "./sign-in" + +// NOTE: the ids React's `useId` gives this field in apps/web, kept so the two DOMs diff clean. +// Base UI's `dirty` compares with the initial value, which is always empty here. +const PASSWORD_FIELD: FieldIds = { control: "base-ui-_r_0_", label: "base-ui-_r_1_", error: "base-ui-_r_5_" } + +/** apps/web's `getErrorMessage`: the API's message, or the browser's fetch failure text. */ +const failureMessage = (failure: SignInFailure): string => + SignInFailure.match(failure, { + Rejected: ({ message }) => message, + Unreachable: () => "Failed to fetch", + Unexpected: () => "Invalid root password", + }) + +const maybeFailureOf = (submission: Submission): Option.Option => + Submission.match(submission, { + NotSubmitted: () => Option.none(), + Submitting: () => Option.none(), + Failed: ({ failure }) => Option.some(failure), + }) + +const passwordField = (model: Model, h: HtmlBuilder): Html => { + const maybeFailure = maybeFailureOf(model.submission) + const isSubmitting = model.submission._tag === "Submitting" + const state: FieldState = { + isInvalid: Option.isSome(maybeFailure), + isDirty: model.password !== "", + isFilled: model.password !== "", + isFocused: model.isPasswordFocused, + isTouched: model.isPasswordTouched, + } + return field(h, { className: "items-stretch", state }, [ + fieldLabel(h, { ids: PASSWORD_FIELD, className: "font-mono text-xs sm:text-xs tracking-[0.02em]", state }, "Root password"), + input(h, { + ids: PASSWORD_FIELD, + state, + attributes: [ + h.Autocomplete("current-password"), + h.Required(true), + h.Type("password"), + h.Value(model.password), + h.Disabled(isSubmitting), + h.OnInput((value) => Message.UpdatedPassword({ value })), + h.OnFocus(Message.FocusedPassword()), + h.OnBlur(Message.BlurredPassword()), + ], + }), + ...Option.match(maybeFailure, { + onNone: () => [], + onSome: (failure) => [fieldError(h, { ids: PASSWORD_FIELD, className: "font-mono", state }, failureMessage(failure))], + }), + ]) +} + +/** `SelfHostedSignInPage` (apps/web/src/routes/sign-in.tsx). */ +export const view = Submodel.defineView((model, h) => + accountLayout(h, [ + h.div( + [h.Class("space-y-8")], + [ + h.div( + [h.Class("space-y-2")], + [ + h.h1( + [h.Class("font-display font-[650] text-[30px] leading-[1.1] tracking-[-0.03em]")], + ["Sign in to Maple"], + ), + h.p( + [h.Class("font-display text-muted-foreground text-sm leading-relaxed")], + ["This deployment is self-hosted. Enter the root password to continue."], + ), + ], + ), + h.form( + [h.Class("space-y-6"), h.OnSubmit(Message.SubmittedSignIn())], + [ + passwordField(model, h), + button(h, { type: "submit", className: "w-full", isLoading: model.submission._tag === "Submitting" }, [ + "Sign in", + ]), + ], + ), + ], + ), + ]), +) diff --git a/apps/web-foldkit/src/platform/api-clients.ts b/apps/web-foldkit/src/platform/api-clients.ts new file mode 100644 index 0000000000..a178a2ed4a --- /dev/null +++ b/apps/web-foldkit/src/platform/api-clients.ts @@ -0,0 +1,81 @@ +import { MapleAiApi, MapleApi, MapleInternalApi } from "@maple/domain/http" +import { MapleApiV2 } from "@maple/domain/http/v2" +import { transformMapleApiClient } from "@maple/web/lib/services/common/api-client-transform" +import { apiBaseUrl } from "@maple/web/lib/services/common/api-base-url" +import { withRequestTimeout } from "@maple/web/lib/services/common/http-client" +import { Context, Effect, Layer, Option } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http" +import { HttpApiClient } from "effect/http-api" +import { MapleAuthApi } from "./auth-api" +import { SessionBearer } from "./session-bearer" + +/** + * apps/web's `MapleFetchHttpClientLive` as an Effect layer: requests to the API carry the session + * bearer, and every request has the same 45 s deadline. + */ +const MapleHttpClientLive = Layer.effect( + HttpClient.HttpClient, + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient + const bearer = yield* SessionBearer + const authorize = (request: HttpClientRequest.HttpClientRequest) => + request.url.startsWith(apiBaseUrl) && request.headers.authorization === undefined + ? Effect.map( + bearer.token, + Option.match({ + onNone: () => request, + onSome: (token) => HttpClientRequest.bearerToken(request, token), + }), + ) + : Effect.succeed(request) + // The base URL is prepended here, not by HttpApiClient: its `baseUrl` is applied after this + // client's request mapping, so `authorize` would only ever see relative URLs. + const absolute = HttpClient.mapRequest(client, HttpClientRequest.prependUrl(apiBaseUrl)) + return withRequestTimeout(HttpClient.mapRequestEffect(absolute, authorize)) + }), +).pipe(Layer.provide([FetchHttpClient.layer, SessionBearer.layer])) + +const clientOptions = { transformClient: transformMapleApiClient } +const makeMapleApi = HttpApiClient.make(MapleApi, clientOptions) +const makeInternalApi = HttpApiClient.make(MapleInternalApi, clientOptions) +const makeApiV2 = HttpApiClient.make(MapleApiV2, clientOptions) +const makeAiApi = HttpApiClient.make(MapleAiApi, clientOptions) + +/** `MapleApiAtomClient`: auth, billing catalog, email at `/api`. */ +export class MapleApiClient extends Context.Service>()( + "@maple/web-foldkit/MapleApiClient", +) { + static readonly layer = Layer.effect(this, makeMapleApi).pipe(Layer.provide(MapleHttpClientLive)) +} + +/** `MapleInternalAtomClient`: the dashboard's private transport at `/internal`. */ +export class MapleInternalApiClient extends Context.Service< + MapleInternalApiClient, + Effect.Success +>()("@maple/web-foldkit/MapleInternalApiClient") { + static readonly layer = Layer.effect(this, makeInternalApi).pipe(Layer.provide(MapleHttpClientLive)) +} + +/** `MapleApiV2AtomClient`: the public v2 API. */ +export class MapleApiV2Client extends Context.Service>()( + "@maple/web-foldkit/MapleApiV2Client", +) { + static readonly layer = Layer.effect(this, makeApiV2).pipe(Layer.provide(MapleHttpClientLive)) +} + +/** `MapleAiAtomClient`: chat and AI endpoints. */ +export class MapleAiClient extends Context.Service>()( + "@maple/web-foldkit/MapleAiClient", +) { + static readonly layer = Layer.effect(this, makeAiApi).pipe(Layer.provide(MapleHttpClientLive)) +} + +/** The runtime's `resources`: built once, shared by every Command and Subscription. */ +export const ResourcesLive = Layer.mergeAll( + MapleAuthApi.layer, + MapleApiClient.layer, + MapleInternalApiClient.layer, + MapleApiV2Client.layer, + MapleAiClient.layer, +) +export type Resources = Layer.Success diff --git a/apps/web-foldkit/src/platform/auth-api.ts b/apps/web-foldkit/src/platform/auth-api.ts new file mode 100644 index 0000000000..5d8033d5ba --- /dev/null +++ b/apps/web-foldkit/src/platform/auth-api.ts @@ -0,0 +1,84 @@ +import { BrowserKeyValueStore } from "@effect/platform-browser" +import { MapleApi, SelfHostedLoginRequest, UnauthorizedError } from "@maple/domain/http" +import { apiBaseUrl } from "@maple/web/lib/services/common/api-base-url" +import { Context, Effect, Layer, Option, Predicate, Schema } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http" +import { HttpApiClient } from "effect/http-api" +import { KeyValueStore } from "effect/persistence" + +/** apps/web's `self-hosted-auth.ts` keeps the root-password session here, per tab. */ +const SELF_HOSTED_TOKEN_STORAGE_KEY = "maple.self_hosted.token" +/** Dispatched when the stored token changes outside `update` (a rejected renewal). */ +export const SELF_HOSTED_AUTH_EVENT = "maple:self-hosted-auth-changed" + +const withSessionStorage = Effect.provide(BrowserKeyValueStore.layerSessionStorage) + +export const readSessionToken: Effect.Effect> = Effect.gen(function* () { + const store = yield* KeyValueStore.KeyValueStore + const token = yield* store.get(SELF_HOSTED_TOKEN_STORAGE_KEY) + return Option.filter(Option.fromNullishOr(token), (value) => value.length > 0) +}).pipe(withSessionStorage, Effect.orElseSucceed(Option.none)) + +/** Storage failures are ignored like apps/web: the session simply does not persist. */ +export const saveSessionToken = (token: string): Effect.Effect => + Effect.gen(function* () { + const store = yield* KeyValueStore.KeyValueStore + yield* store.set(SELF_HOSTED_TOKEN_STORAGE_KEY, token) + }).pipe(withSessionStorage, Effect.ignore) + +export const clearSessionToken: Effect.Effect = Effect.gen(function* () { + const store = yield* KeyValueStore.KeyValueStore + yield* store.remove(SELF_HOSTED_TOKEN_STORAGE_KEY) +}).pipe(withSessionStorage, Effect.ignore) + +export const announceSessionTokenChanged: Effect.Effect = Effect.sync(() => + window.dispatchEvent(new Event(SELF_HOSTED_AUTH_EVENT)), +) + +/** The session check or renewal could not settle the session. `Rejected` ends it. */ +export class SessionCheckError extends Schema.TaggedError()( + "@maple/web-foldkit/SessionCheckError", + { reason: Schema.Literals(["Rejected", "Unreachable"]), cause: Schema.Defect() }, +) {} + +const makeClient = (transformClient?: (client: HttpClient.HttpClient) => HttpClient.HttpClient) => + HttpApiClient.make(MapleApi, { baseUrl: apiBaseUrl, transformClient }) + +const withBearer = (token: string) => HttpClient.mapRequest(HttpClientRequest.bearerToken(token)) + +/** Session check: any answer but a transport failure ends the session (`resolveSelfHostedRouterAuth`). */ +const classifySessionCheck = (cause: unknown) => + new SessionCheckError({ reason: Predicate.isTagged(cause, "HttpClientError") ? "Unreachable" : "Rejected", cause }) + +/** Renewal: only a 401 ends the session; anything else keeps the current token (`renewSessionToken`). */ +const classifyRenewal = (cause: unknown) => + new SessionCheckError({ reason: Schema.is(UnauthorizedError)(cause) ? "Rejected" : "Unreachable", cause }) + +/** + * The unauthenticated `/api/auth` calls: login, session check, renewal. A plain fetch client on + * purpose: the bearer-injecting client would wait on the renewal it is performing. + */ +export class MapleAuthApi extends Context.Service()("@maple/web-foldkit/MapleAuthApi", { + make: Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient + const provide = Effect.provideService(HttpClient.HttpClient, httpClient) + const anonymous = yield* makeClient() + return { + login: (password: string) => anonymous.authPublic.login({ payload: new SelfHostedLoginRequest({ password }) }), + session: (token: string) => + makeClient(withBearer(token)).pipe( + Effect.flatMap((client) => client.auth.session()), + provide, + Effect.mapError(classifySessionCheck), + ), + refresh: (token: string) => + makeClient(withBearer(token)).pipe( + Effect.flatMap((client) => client.auth.sessionRefresh()), + provide, + Effect.mapError(classifyRenewal), + ), + } + }), +}) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(FetchHttpClient.layer)) +} diff --git a/apps/web-foldkit/src/platform/clerk.ts b/apps/web-foldkit/src/platform/clerk.ts new file mode 100644 index 0000000000..c45646d643 --- /dev/null +++ b/apps/web-foldkit/src/platform/clerk.ts @@ -0,0 +1,101 @@ +import { OrgId, UserId } from "@maple/domain" +import { purgeForeignClerkCookies } from "@maple/web/lib/services/common/clerk-cookie-guard" +import { Effect, Option, Queue, Schema, Stream } from "effect" + +/** The slice of clerk-js this app reads. `window.Clerk` is what the parity harness stubs. */ +interface ClerkResources { + readonly user?: { readonly id: string } | null + readonly session?: { readonly getToken: () => Promise } | null + readonly organization?: { readonly id: string } | null +} +interface ClerkGlobal extends ClerkResources { + readonly load: (options: Readonly>) => Promise + readonly addListener: (listener: (resources: ClerkResources) => void) => () => void + readonly mountSignIn: (element: HTMLDivElement, props: Readonly>) => void + readonly unmountSignIn: (element: HTMLDivElement) => void +} +declare global { + interface Window { + Clerk?: ClerkGlobal + } +} + +export class ClerkLoadError extends Schema.TaggedError()("@maple/web-foldkit/ClerkLoadError", { + message: Schema.String, + cause: Schema.Defect(), +}) {} + +/** What the app reads from a Clerk session (`useAuth()` in apps/web). */ +export const ClerkSnapshot = Schema.Struct({ + maybeUserId: Schema.Option(UserId), + maybeOrgId: Schema.Option(OrgId), +}) +export type ClerkSnapshot = typeof ClerkSnapshot.Type + +const toSnapshot = (resources: ClerkResources): ClerkSnapshot => ({ + maybeUserId: Option.map(Option.fromNullishOr(resources.user?.id), UserId.make), + maybeOrgId: Option.map(Option.fromNullishOr(resources.organization?.id), OrgId.make), +}) + +const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY?.trim() ?? "" +const signInUrl = import.meta.env.VITE_CLERK_SIGN_IN_URL?.trim() || "/sign-in" +const signUpUrl = import.meta.env.VITE_CLERK_SIGN_UP_URL?.trim() || "/sign-up" + +/** `pk_test_`: where @clerk/clerk-react loads clerk-js from. */ +const clerkJsUrl = (key: string) => + `https://${atob(key.split("_").slice(2).join("_")).replace(/\$$/, "")}/npm/@clerk/clerk-js@5/dist/clerk.browser.js` + +const injectClerkJs = Effect.callback((resume) => { + const script = document.createElement("script") + script.src = clerkJsUrl(publishableKey) + script.async = true + script.crossOrigin = "anonymous" + script.setAttribute("data-clerk-js-script", "true") + script.setAttribute("data-clerk-publishable-key", publishableKey) + script.addEventListener("load", () => resume(Effect.void)) + script.addEventListener("error", (cause) => + resume(Effect.fail(new ClerkLoadError({ message: "Failed to load Clerk", cause }))), + ) + document.head.appendChild(script) +}) + +const currentClerk = Effect.suspend(() => + Option.match(Option.fromNullishOr(window.Clerk), { + onNone: () => Effect.fail(new ClerkLoadError({ message: "window.Clerk is missing", cause: undefined })), + onSome: Effect.succeed, + }), +) + +/** Loads clerk-js the way `` does (unless a stub already sits on `window`). */ +export const loadClerk: Effect.Effect = Effect.gen(function* () { + yield* Effect.promise(() => purgeForeignClerkCookies(publishableKey)) + if (window.Clerk === undefined) { + yield* injectClerkJs + } + const clerk = yield* currentClerk + yield* Effect.tryPromise({ + try: () => clerk.load({ signInUrl, signUpUrl, signUpFallbackRedirectUrl: "/quick-start", afterSignOutUrl: signInUrl }), + catch: (cause) => new ClerkLoadError({ message: "Clerk failed to load", cause }), + }) + return toSnapshot(clerk) +}) + +/** Every Clerk emit (session touch, org switch, sign-out), as snapshots. */ +export const clerkSnapshots: Stream.Stream = Stream.callback((queue) => + Option.match(Option.fromNullishOr(window.Clerk), { + onNone: () => Effect.void, + onSome: (clerk) => + Effect.acquireRelease( + Effect.sync(() => clerk.addListener((resources) => Queue.offerUnsafe(queue, toSnapshot(resources)))), + (unsubscribe) => Effect.sync(unsubscribe), + ), + }), +) + +/** The session JWT for API calls (`getToken()` in apps/web's ClerkAuthBridge). */ +export const clerkSessionToken: Effect.Effect> = Effect.tryPromise( + () => window.Clerk?.session?.getToken() ?? Promise.resolve(null), +).pipe( + Effect.map(Option.fromNullishOr), + Effect.orElseSucceed(Option.none), +) diff --git a/apps/web-foldkit/src/platform/session-bearer.ts b/apps/web-foldkit/src/platform/session-bearer.ts new file mode 100644 index 0000000000..02ff5e30cb --- /dev/null +++ b/apps/web-foldkit/src/platform/session-bearer.ts @@ -0,0 +1,54 @@ +import { readJwtExpMs } from "@maple/web/lib/services/common/auth-headers" +import { isClerkAuthEnabled } from "@maple/web/lib/services/common/auth-mode" +import { Clock, Context, Duration, Effect, Layer, Option, Semaphore } from "effect" +import { announceSessionTokenChanged, clearSessionToken, MapleAuthApi, readSessionToken, saveSessionToken } from "./auth-api" +import { clerkSessionToken } from "./clerk" + +/** Renew this far ahead of expiry, like apps/web (`RENEW_WHEN_REMAINING_MS`). */ +const RENEW_AHEAD = Duration.minutes(5) + +/** The bearer token API requests carry: the self-hosted session or Clerk's session JWT. */ +export class SessionBearer extends Context.Service> }>()( + "@maple/web-foldkit/SessionBearer", +) { + static readonly layer = Layer.effect( + this, + isClerkAuthEnabled ? Effect.succeed({ token: clerkSessionToken }) : makeSelfHosted(), + ).pipe(Layer.provide(MapleAuthApi.layer)) +} + +function makeSelfHosted() { + return Effect.gen(function* () { + const auth = yield* MapleAuthApi + // One renewal at a time: a burst of requests finding the same near-expiry token renews once. + const renewal = yield* Semaphore.make(1) + + const renew = (token: string) => + auth.refresh(token).pipe( + Effect.tap((renewed) => saveSessionToken(renewed.token)), + Effect.map((renewed) => Option.some(renewed.token)), + Effect.catchTag("@maple/web-foldkit/SessionCheckError", (error) => + error.reason === "Rejected" + ? Effect.as(Effect.andThen(clearSessionToken, announceSessionTokenChanged), Option.none()) + : Effect.succeed(Option.some(token)), + ), + ) + + const freshToken = (token: string) => + Effect.gen(function* () { + const expiresAt = readJwtExpMs(token) + const now = yield* Clock.currentTimeMillis + // A token without `exp` predates expiring sessions: it has no deadline to renew ahead of. + if (expiresAt === undefined || expiresAt - now > Duration.toMillis(RENEW_AHEAD)) { + return Option.some(token) + } + return yield* renew(token) + }) + + const token = readSessionToken.pipe( + Effect.flatMap(Option.match({ onNone: () => Effect.succeedNone, onSome: freshToken })), + renewal.withPermits(1), + ) + return { token } + }) +} diff --git a/apps/web-foldkit/src/route/app-route.ts b/apps/web-foldkit/src/route/app-route.ts new file mode 100644 index 0000000000..4715531888 --- /dev/null +++ b/apps/web-foldkit/src/route/app-route.ts @@ -0,0 +1,101 @@ +import { AlertRuleId, AnomalyIncidentId, DashboardId, ErrorIssueId } from "@maple/domain/http" +import { Schema } from "effect" +import { defineRouteUnion } from "foldkit/route" +import * as Search from "./search" + +const WorkloadKind = Search.InfraKubernetesWorkloadParams.fields.kind + +/** Every user-facing route of apps/web (`routeTree.gen.ts` minus `/lab`), with its search. */ +export const AppRoute = defineRouteUnion({ + Home: { search: Search.HomeSearch }, + Account: { search: Search.AccountSearch }, + Chat: { search: Search.ChatSearch }, + CliLogin: { search: Search.CliLoginSearch }, + Connectors: { search: Search.ConnectorsSearch }, + Developer: { search: Search.DeveloperSearch }, + Integrations: { search: Search.IntegrationsSearch }, + Mcp: {}, + McpAuthorize: { search: Search.McpAuthorizeSearch }, + OrgRequired: { search: Search.OrgRequiredSearch }, + QuickStart: { search: Search.QuickStartSearch }, + SelectPlan: { search: Search.SelectPlanSearch }, + ServiceMap: { search: Search.ServiceMapSearch }, + Settings: { search: Search.SettingsSearch }, + SignIn: { search: Search.SignInSearch }, + SignUp: { search: Search.SignUpSearch }, + Unsubscribe: { search: Search.UnsubscribeSearch }, + AgentSession: { sessionId: Schema.String, search: Search.AgentSessionSearch }, + AlertRule: { ruleId: AlertRuleId, search: Search.AlertRuleSearch }, + AlertsCreate: { search: Search.AlertsCreateSearch }, + Anomaly: { incidentId: AnomalyIncidentId }, + CodeReviewCi: { search: Search.CodeReviewCiSearch }, + CodeReviewIssues: { search: Search.CodeReviewIssuesSearch }, + CodeReviewPullRequests: { search: Search.CodeReviewPullRequestsSearch }, + CodeReviewSettings: {}, + Dashboard: { dashboardId: DashboardId, search: Search.DashboardSearch }, + DashboardsTemplates: { search: Search.DashboardsTemplatesSearch }, + InfraLegacyHost: { hostName: Schema.String }, + InfraDiscover: {}, + Investigation: { id: Schema.String, search: Search.InvestigationSearch }, + Log: { logId: Schema.String }, + Metric: { metricName: Schema.String, search: Search.MetricSearch }, + Recommendation: { recommendationKey: Schema.String }, + Release: { commitSha: Schema.String, search: Search.ReleaseSearch }, + Replay: { sessionId: Schema.String, search: Search.ReplaySearch }, + Service: { serviceName: Schema.String, search: Search.ServiceSearch }, + Share: { token: Schema.String, search: Search.ShareSearch }, + Trace: { traceId: Schema.String, search: Search.TraceSearch }, + AgentSessions: { search: Search.AgentSessionsSearch }, + Alerts: { search: Search.AlertsSearch }, + Analytics: { search: Search.AnalyticsSearch }, + Anomalies: { search: Search.AnomaliesSearch }, + CodeReview: { search: Search.CodeReviewSearch }, + Dashboards: { search: Search.DashboardsSearch }, + Errors: { search: Search.ErrorsSearch }, + Infra: { search: Search.InfraSearch }, + Investigations: { search: Search.InvestigationsSearch }, + Logs: { search: Search.LogsSearch }, + Metrics: { search: Search.MetricsSearch }, + Releases: { search: Search.ReleasesSearch }, + Replays: { search: Search.ReplaysSearch }, + Services: { search: Search.ServicesSearch }, + Traces: { search: Search.TracesSearch }, + AgentSessionTool: { toolName: Schema.String, search: Search.AgentSessionToolSearch }, + AlertIncident: { incidentId: Schema.String, search: Search.AlertIncidentSearch }, + ErrorIssue: { issueId: ErrorIssueId, search: Search.ErrorIssueSearch }, + InfraCloudflareZone: { zoneName: Schema.String, search: Search.InfraCloudflareZoneSearch }, + InfraContainer: { containerName: Schema.String, search: Search.InfraContainerSearch }, + InfraHost: { hostName: Schema.String, search: Search.InfraHostSearch }, + InfraPlanetscaleDatabase: { dbName: Schema.String, search: Search.InfraPlanetscaleDatabaseSearch }, + InfraRailwayService: { serviceId: Schema.String, search: Search.InfraRailwayServiceSearch }, + AgentSessionTools: { search: Search.AgentSessionToolsSearch }, + ErrorIssues: { search: Search.ErrorIssuesSearch }, + InfraCloudflare: { search: Search.InfraCloudflareSearch }, + InfraContainers: { search: Search.InfraContainersSearch }, + InfraGcp: { search: Search.InfraGcpSearch }, + InfraHosts: { search: Search.InfraHostsSearch }, + InfraKubernetes: { search: Search.InfraKubernetesSearch }, + InfraPlanetscale: { search: Search.InfraPlanetscaleSearch }, + InfraRailway: { search: Search.InfraRailwaySearch }, + DashboardWidget: { dashboardId: DashboardId, widgetId: Schema.String, search: Search.DashboardWidgetSearch }, + InfraCloudflareWorker: { scriptName: Schema.String, search: Search.InfraCloudflareWorkerSearch }, + InfraGcpResource: { service: Schema.String, name: Schema.String, search: Search.InfraGcpResourceSearch }, + InfraKubernetesNode: { nodeName: Schema.String, search: Search.InfraKubernetesNodeSearch }, + InfraKubernetesPod: { podName: Schema.String, search: Search.InfraKubernetesPodSearch }, + InfraKubernetesService: { serviceName: Schema.String, search: Search.InfraKubernetesServiceSearch }, + InfraKubernetesNodes: { search: Search.InfraKubernetesNodesSearch }, + InfraKubernetesPods: { search: Search.InfraKubernetesPodsSearch }, + InfraKubernetesServices: { search: Search.InfraKubernetesServicesSearch }, + InfraKubernetesWorkloads: { search: Search.InfraKubernetesWorkloadsSearch }, + InfraKubernetesWorkload: { + kind: WorkloadKind, + workloadName: Schema.String, + search: Search.InfraKubernetesWorkloadSearch, + }, + NotFound: { path: Schema.String }, +}) +export type AppRoute = typeof AppRoute.Type +export type RouteTag = AppRoute["_tag"] + +/** Paths that render without a session (`isPublicPath` in apps/web/src/lib/public-routes.ts). */ +export const isPublicRoute = AppRoute.isAnyOf(["SignIn", "SignUp", "OrgRequired", "Unsubscribe", "Share"]) diff --git a/apps/web-foldkit/src/route/index.ts b/apps/web-foldkit/src/route/index.ts new file mode 100644 index 0000000000..6c1184eb72 --- /dev/null +++ b/apps/web-foldkit/src/route/index.ts @@ -0,0 +1,3 @@ +export { AppRoute, isPublicRoute, type RouteTag } from "./app-route" +export { Redirect, routeRedirect } from "./redirect" +export { hrefOf, routers, urlToAppRoute } from "./routers" diff --git a/apps/web-foldkit/src/route/redirect.ts b/apps/web-foldkit/src/route/redirect.ts new file mode 100644 index 0000000000..85167366ad --- /dev/null +++ b/apps/web-foldkit/src/route/redirect.ts @@ -0,0 +1,55 @@ +import { Option, Schema } from "effect" +import { AppRoute } from "./app-route" +import { hrefOf } from "./routers" +import { ErrorsSearch } from "./search" + +/** Where a route sends the browser instead of rendering, and whether it replaces history. */ +export const Redirect = Schema.Struct({ url: Schema.String, isReplace: Schema.Boolean }) +export type Redirect = typeof Redirect.Type + +const VIEW_FOR_WORKFLOW_STATE = new Map([ + ["triage", "open"], + ["regressed", "open"], + ["todo", "open"], + ["in_progress", "open"], + ["in_review", "open"], + ["verifying", "closed"], + ["done", "closed"], + ["cancelled", "closed"], + ["wontfix", "closed"], + ["all", "all"], +]) + +const decodeErrorsSearch = Schema.decodeUnknownOption(ErrorsSearch) + +/** Old issues-list links carry `workflowState` and `severity`; the hub defaults to Open and all severities. */ +const errorsSearchOf = ({ search }: typeof AppRoute.ErrorIssues.Type): typeof ErrorsSearch.Type => { + const view = VIEW_FOR_WORKFLOW_STATE.get(search.workflowState) + return Option.getOrElse( + decodeErrorsSearch({ + view: view === "open" ? undefined : view, + severity: search.severity === "all" ? undefined : search.severity, + }), + () => ({}), + ) +} + +const redirect = (to: AppRoute, isReplace: boolean) => Option.some(Redirect.make({ url: hrefOf(to), isReplace })) + +/** The `beforeLoad` redirects of legacy route files (connectors, developer, infra, errors/issues). */ +export const routeRedirect = (route: AppRoute): Option.Option => + AppRoute.matchOrElse>(route, { + Connectors: ({ search }) => + redirect( + AppRoute.Integrations({ + search: search.tab === "prometheus" || search.tab === "cloudflare" ? { integration: search.tab } : {}, + }), + false, + ), + Developer: ({ search }) => + redirect(AppRoute.Settings({ search: { tab: search.tab === "ingestion" ? "ingestion" : "api-keys" } }), true), + InfraDiscover: () => redirect(AppRoute.Infra({ search: {} }), true), + InfraLegacyHost: ({ hostName }) => redirect(AppRoute.InfraHost({ hostName, search: {} }), true), + InfraKubernetes: ({ search }) => redirect(AppRoute.InfraKubernetesPods({ search }), true), + ErrorIssues: (issues) => redirect(AppRoute.Errors({ search: errorsSearchOf(issues) }), true), + }, () => Option.none()) diff --git a/apps/web-foldkit/src/route/route.test.ts b/apps/web-foldkit/src/route/route.test.ts new file mode 100644 index 0000000000..a5f704467f --- /dev/null +++ b/apps/web-foldkit/src/route/route.test.ts @@ -0,0 +1,44 @@ +import { Option } from "effect" +import { fromString } from "foldkit/url" +import { describe, expect, test } from "vitest" +import { AppRoute, hrefOf, routeRedirect, urlToAppRoute } from "./index" + +const parse = (path: string) => + urlToAppRoute(Option.getOrThrow(fromString(`http://maple.test${path}`))) + +describe("routes", () => { + test("search params decode with TanStack's JSON-per-value format", () => { + const route = parse('/traces?services=%5B%22api%22%5D&hasError=true&timePreset=7d') + expect(route).toMatchObject({ _tag: "Traces", search: { services: ["api"], hasError: true, timePreset: "7d" } }) + }) + + test("path params are percent-decoded and printed back encoded", () => { + const route = parse("/services/checkout%20api") + expect(route).toMatchObject({ _tag: "Service", serviceName: "checkout api" }) + expect(hrefOf(route)).toBe("/services/checkout%20api") + }) + + test("a literal sibling wins over a param route", () => { + expect(parse("/infra/hosts")._tag).toBe("InfraHosts") + expect(parse("/alerts/create")._tag).toBe("AlertsCreate") + }) + + test("sign-in round-trips its redirect_url", () => { + const href = hrefOf(AppRoute.SignIn({ search: { redirect_url: "/logs?x=1" } })) + expect(parse(href)).toMatchObject({ _tag: "SignIn", search: { redirect_url: "/logs?x=1" } }) + }) + + test("legacy beforeLoad redirects", () => { + expect(routeRedirect(parse("/developer?tab=ingestion"))).toEqual( + Option.some({ url: "/settings?tab=ingestion", isReplace: true }), + ) + expect(routeRedirect(parse("/errors/issues?workflowState=done&severity=all"))).toEqual( + Option.some({ url: "/errors?view=closed", isReplace: true }), + ) + expect(routeRedirect(parse("/infra/discover"))).toEqual(Option.some({ url: "/infra", isReplace: true })) + }) + + test("an unknown path is NotFound", () => { + expect(parse("/nope/nope")).toEqual(AppRoute.NotFound({ path: "/nope/nope" })) + }) +}) diff --git a/apps/web-foldkit/src/route/routers.ts b/apps/web-foldkit/src/route/routers.ts new file mode 100644 index 0000000000..0f04d45d24 --- /dev/null +++ b/apps/web-foldkit/src/route/routers.ts @@ -0,0 +1,268 @@ +import { AlertRuleId, AnomalyIncidentId, DashboardId, ErrorIssueId } from "@maple/domain/http" +import { pipe, Schema } from "effect" +import { Route } from "foldkit" +import { query, schemaSegment, slash } from "foldkit/route" +import { AppRoute } from "./app-route" +import * as Search from "./search" +import { path, segment } from "./segment" +import { tanstackSearch } from "./tanstack-search" + +const search = (schema: Schema.Codec) => query(tanstackSearch(schema)) + +const WorkloadKind = Search.InfraKubernetesWorkloadParams.fields.kind + +/** One biparser per route tag: parses URLs and prints them back (`hrefOf`). */ +export const routers = { + Home: pipe(Route.root, search(Search.HomeSearch), Route.mapTo(AppRoute.Home)), + Account: pipe(path("account"), search(Search.AccountSearch), Route.mapTo(AppRoute.Account)), + Chat: pipe(path("chat"), search(Search.ChatSearch), Route.mapTo(AppRoute.Chat)), + CliLogin: pipe(path("cli-login"), search(Search.CliLoginSearch), Route.mapTo(AppRoute.CliLogin)), + Connectors: pipe(path("connectors"), search(Search.ConnectorsSearch), Route.mapTo(AppRoute.Connectors)), + Developer: pipe(path("developer"), search(Search.DeveloperSearch), Route.mapTo(AppRoute.Developer)), + Integrations: pipe(path("integrations"), search(Search.IntegrationsSearch), Route.mapTo(AppRoute.Integrations)), + Mcp: pipe(path("mcp"), Route.mapTo(AppRoute.Mcp)), + McpAuthorize: pipe(path("mcp-authorize"), search(Search.McpAuthorizeSearch), Route.mapTo(AppRoute.McpAuthorize)), + OrgRequired: pipe(path("org-required"), search(Search.OrgRequiredSearch), Route.mapTo(AppRoute.OrgRequired)), + QuickStart: pipe(path("quick-start"), search(Search.QuickStartSearch), Route.mapTo(AppRoute.QuickStart)), + SelectPlan: pipe(path("select-plan"), search(Search.SelectPlanSearch), Route.mapTo(AppRoute.SelectPlan)), + ServiceMap: pipe(path("service-map"), search(Search.ServiceMapSearch), Route.mapTo(AppRoute.ServiceMap)), + Settings: pipe(path("settings"), search(Search.SettingsSearch), Route.mapTo(AppRoute.Settings)), + SignIn: pipe(path("sign-in"), search(Search.SignInSearch), Route.mapTo(AppRoute.SignIn)), + SignUp: pipe(path("sign-up"), search(Search.SignUpSearch), Route.mapTo(AppRoute.SignUp)), + Unsubscribe: pipe(path("unsubscribe"), search(Search.UnsubscribeSearch), Route.mapTo(AppRoute.Unsubscribe)), + AgentSession: pipe( + path("agent-sessions"), + slash(segment("sessionId")), + search(Search.AgentSessionSearch), + Route.mapTo(AppRoute.AgentSession), + ), + AlertRule: pipe( + path("alerts"), + slash(schemaSegment("ruleId", AlertRuleId)), + search(Search.AlertRuleSearch), + Route.mapTo(AppRoute.AlertRule), + ), + AlertsCreate: pipe(path("alerts", "create"), search(Search.AlertsCreateSearch), Route.mapTo(AppRoute.AlertsCreate)), + Anomaly: pipe(path("anomalies"), slash(schemaSegment("incidentId", AnomalyIncidentId)), Route.mapTo(AppRoute.Anomaly)), + CodeReviewCi: pipe(path("code-review", "ci"), search(Search.CodeReviewCiSearch), Route.mapTo(AppRoute.CodeReviewCi)), + CodeReviewIssues: pipe( + path("code-review", "issues"), + search(Search.CodeReviewIssuesSearch), + Route.mapTo(AppRoute.CodeReviewIssues), + ), + CodeReviewPullRequests: pipe( + path("code-review", "pull-requests"), + search(Search.CodeReviewPullRequestsSearch), + Route.mapTo(AppRoute.CodeReviewPullRequests), + ), + CodeReviewSettings: pipe(path("code-review", "settings"), Route.mapTo(AppRoute.CodeReviewSettings)), + Dashboard: pipe( + path("dashboards"), + slash(schemaSegment("dashboardId", DashboardId)), + search(Search.DashboardSearch), + Route.mapTo(AppRoute.Dashboard), + ), + DashboardsTemplates: pipe( + path("dashboards", "templates"), + search(Search.DashboardsTemplatesSearch), + Route.mapTo(AppRoute.DashboardsTemplates), + ), + InfraLegacyHost: pipe(path("infra"), slash(segment("hostName")), Route.mapTo(AppRoute.InfraLegacyHost)), + InfraDiscover: pipe(path("infra", "discover"), Route.mapTo(AppRoute.InfraDiscover)), + Investigation: pipe( + path("investigations"), + slash(segment("id")), + search(Search.InvestigationSearch), + Route.mapTo(AppRoute.Investigation), + ), + Log: pipe(path("logs"), slash(segment("logId")), Route.mapTo(AppRoute.Log)), + Metric: pipe(path("metrics"), slash(segment("metricName")), search(Search.MetricSearch), Route.mapTo(AppRoute.Metric)), + Recommendation: pipe( + path("recommendations"), + slash(segment("recommendationKey")), + Route.mapTo(AppRoute.Recommendation), + ), + Release: pipe(path("releases"), slash(segment("commitSha")), search(Search.ReleaseSearch), Route.mapTo(AppRoute.Release)), + Replay: pipe(path("replays"), slash(segment("sessionId")), search(Search.ReplaySearch), Route.mapTo(AppRoute.Replay)), + Service: pipe( + path("services"), + slash(segment("serviceName")), + search(Search.ServiceSearch), + Route.mapTo(AppRoute.Service), + ), + Share: pipe(path("share"), slash(segment("token")), search(Search.ShareSearch), Route.mapTo(AppRoute.Share)), + Trace: pipe(path("traces"), slash(segment("traceId")), search(Search.TraceSearch), Route.mapTo(AppRoute.Trace)), + AgentSessions: pipe(path("agent-sessions"), search(Search.AgentSessionsSearch), Route.mapTo(AppRoute.AgentSessions)), + Alerts: pipe(path("alerts"), search(Search.AlertsSearch), Route.mapTo(AppRoute.Alerts)), + Analytics: pipe(path("analytics"), search(Search.AnalyticsSearch), Route.mapTo(AppRoute.Analytics)), + Anomalies: pipe(path("anomalies"), search(Search.AnomaliesSearch), Route.mapTo(AppRoute.Anomalies)), + CodeReview: pipe(path("code-review"), search(Search.CodeReviewSearch), Route.mapTo(AppRoute.CodeReview)), + Dashboards: pipe(path("dashboards"), search(Search.DashboardsSearch), Route.mapTo(AppRoute.Dashboards)), + Errors: pipe(path("errors"), search(Search.ErrorsSearch), Route.mapTo(AppRoute.Errors)), + Infra: pipe(path("infra"), search(Search.InfraSearch), Route.mapTo(AppRoute.Infra)), + Investigations: pipe(path("investigations"), search(Search.InvestigationsSearch), Route.mapTo(AppRoute.Investigations)), + Logs: pipe(path("logs"), search(Search.LogsSearch), Route.mapTo(AppRoute.Logs)), + Metrics: pipe(path("metrics"), search(Search.MetricsSearch), Route.mapTo(AppRoute.Metrics)), + Releases: pipe(path("releases"), search(Search.ReleasesSearch), Route.mapTo(AppRoute.Releases)), + Replays: pipe(path("replays"), search(Search.ReplaysSearch), Route.mapTo(AppRoute.Replays)), + Services: pipe(path("services"), search(Search.ServicesSearch), Route.mapTo(AppRoute.Services)), + Traces: pipe(path("traces"), search(Search.TracesSearch), Route.mapTo(AppRoute.Traces)), + AgentSessionTool: pipe( + path("agent-sessions", "tools"), + slash(segment("toolName")), + search(Search.AgentSessionToolSearch), + Route.mapTo(AppRoute.AgentSessionTool), + ), + AlertIncident: pipe( + path("alerts", "incidents"), + slash(segment("incidentId")), + search(Search.AlertIncidentSearch), + Route.mapTo(AppRoute.AlertIncident), + ), + ErrorIssue: pipe( + path("errors", "issues"), + slash(schemaSegment("issueId", ErrorIssueId)), + search(Search.ErrorIssueSearch), + Route.mapTo(AppRoute.ErrorIssue), + ), + InfraCloudflareZone: pipe( + path("infra", "cloudflare"), + slash(segment("zoneName")), + search(Search.InfraCloudflareZoneSearch), + Route.mapTo(AppRoute.InfraCloudflareZone), + ), + InfraContainer: pipe( + path("infra", "containers"), + slash(segment("containerName")), + search(Search.InfraContainerSearch), + Route.mapTo(AppRoute.InfraContainer), + ), + InfraHost: pipe( + path("infra", "hosts"), + slash(segment("hostName")), + search(Search.InfraHostSearch), + Route.mapTo(AppRoute.InfraHost), + ), + InfraPlanetscaleDatabase: pipe( + path("infra", "planetscale"), + slash(segment("dbName")), + search(Search.InfraPlanetscaleDatabaseSearch), + Route.mapTo(AppRoute.InfraPlanetscaleDatabase), + ), + InfraRailwayService: pipe( + path("infra", "railway"), + slash(segment("serviceId")), + search(Search.InfraRailwayServiceSearch), + Route.mapTo(AppRoute.InfraRailwayService), + ), + AgentSessionTools: pipe( + path("agent-sessions", "tools"), + search(Search.AgentSessionToolsSearch), + Route.mapTo(AppRoute.AgentSessionTools), + ), + ErrorIssues: pipe(path("errors", "issues"), search(Search.ErrorIssuesSearch), Route.mapTo(AppRoute.ErrorIssues)), + InfraCloudflare: pipe(path("infra", "cloudflare"), search(Search.InfraCloudflareSearch), Route.mapTo(AppRoute.InfraCloudflare)), + InfraContainers: pipe(path("infra", "containers"), search(Search.InfraContainersSearch), Route.mapTo(AppRoute.InfraContainers)), + InfraGcp: pipe(path("infra", "gcp"), search(Search.InfraGcpSearch), Route.mapTo(AppRoute.InfraGcp)), + InfraHosts: pipe(path("infra", "hosts"), search(Search.InfraHostsSearch), Route.mapTo(AppRoute.InfraHosts)), + InfraKubernetes: pipe(path("infra", "kubernetes"), search(Search.InfraKubernetesSearch), Route.mapTo(AppRoute.InfraKubernetes)), + InfraPlanetscale: pipe( + path("infra", "planetscale"), + search(Search.InfraPlanetscaleSearch), + Route.mapTo(AppRoute.InfraPlanetscale), + ), + InfraRailway: pipe(path("infra", "railway"), search(Search.InfraRailwaySearch), Route.mapTo(AppRoute.InfraRailway)), + DashboardWidget: pipe( + path("dashboards"), + slash(schemaSegment("dashboardId", DashboardId)), + slash(path("widgets")), + slash(segment("widgetId")), + search(Search.DashboardWidgetSearch), + Route.mapTo(AppRoute.DashboardWidget), + ), + InfraCloudflareWorker: pipe( + path("infra", "cloudflare", "workers"), + slash(segment("scriptName")), + search(Search.InfraCloudflareWorkerSearch), + Route.mapTo(AppRoute.InfraCloudflareWorker), + ), + InfraGcpResource: pipe( + path("infra", "gcp"), + slash(segment("service")), + slash(segment("name")), + search(Search.InfraGcpResourceSearch), + Route.mapTo(AppRoute.InfraGcpResource), + ), + InfraKubernetesNode: pipe( + path("infra", "kubernetes", "nodes"), + slash(segment("nodeName")), + search(Search.InfraKubernetesNodeSearch), + Route.mapTo(AppRoute.InfraKubernetesNode), + ), + InfraKubernetesPod: pipe( + path("infra", "kubernetes", "pods"), + slash(segment("podName")), + search(Search.InfraKubernetesPodSearch), + Route.mapTo(AppRoute.InfraKubernetesPod), + ), + InfraKubernetesService: pipe( + path("infra", "kubernetes", "services"), + slash(segment("serviceName")), + search(Search.InfraKubernetesServiceSearch), + Route.mapTo(AppRoute.InfraKubernetesService), + ), + InfraKubernetesNodes: pipe( + path("infra", "kubernetes", "nodes"), + search(Search.InfraKubernetesNodesSearch), + Route.mapTo(AppRoute.InfraKubernetesNodes), + ), + InfraKubernetesPods: pipe( + path("infra", "kubernetes", "pods"), + search(Search.InfraKubernetesPodsSearch), + Route.mapTo(AppRoute.InfraKubernetesPods), + ), + InfraKubernetesServices: pipe( + path("infra", "kubernetes", "services"), + search(Search.InfraKubernetesServicesSearch), + Route.mapTo(AppRoute.InfraKubernetesServices), + ), + InfraKubernetesWorkloads: pipe( + path("infra", "kubernetes", "workloads"), + search(Search.InfraKubernetesWorkloadsSearch), + Route.mapTo(AppRoute.InfraKubernetesWorkloads), + ), + InfraKubernetesWorkload: pipe( + path("infra", "kubernetes", "workloads"), + slash(schemaSegment("kind", WorkloadKind)), + slash(segment("workloadName")), + search(Search.InfraKubernetesWorkloadSearch), + Route.mapTo(AppRoute.InfraKubernetesWorkload), + ), +} as const + +// Literal routes come before the param routes that would also match them (`/infra/hosts`). +export const urlToAppRoute = Route.parseUrlWithFallback( + Route.oneOf( + routers.AlertsCreate, + routers.DashboardsTemplates, + routers.InfraDiscover, + routers.InfraCloudflare, + routers.InfraContainers, + routers.InfraGcp, + routers.InfraHosts, + routers.InfraKubernetes, + routers.InfraPlanetscale, + routers.InfraRailway, + routers.AgentSessionTools, + routers.ErrorIssues, + ...Object.values(routers), + ), + AppRoute.NotFound, +) + +/** The URL a route prints to; `urlToAppRoute` of it gives the route back. */ +export const hrefOf = (route: AppRoute): string => + AppRoute.match(route, { + ...routers, + NotFound: ({ path }) => path, + }) diff --git a/apps/web-foldkit/src/route/search.ts b/apps/web-foldkit/src/route/search.ts new file mode 100644 index 0000000000..83eb9d9505 --- /dev/null +++ b/apps/web-foldkit/src/route/search.ts @@ -0,0 +1,821 @@ +// Mirrors every `validateSearch` schema of apps/web/src/routes, so this app accepts the same URLs. +// Keep in sync by hand: the legacy route files are .tsx and cannot be imported here. +import { Schema } from "effect" +import { + AiSessionSortDir, + AiSessionSortKey, + CiBranchName, + CiPipelineName, + CiRunResult, + IssueKind, + PrReviewCategory, + PrReviewFindingStatus, + PrReviewId, + PrReviewSeverity, + VcsRepositoryId, +} from "@maple/domain/http" +import { QUERY_BUILDER_METRIC_TYPES } from "@maple/query-model" + +// apps/web/src/lib/search-params.ts +// Readonly here: legacy marks these `mutable` only for its React call sites. +const StringArray = Schema.Array(Schema.String) +const StringArrayFromJsonString = Schema.fromJsonString(Schema.Array(Schema.String)) + +export const BooleanFromStringParam = Schema.fromJsonString(Schema.Boolean) + +export const NumberFromStringParam = Schema.fromJsonString(Schema.Number) + +export const OptionalStringArrayParam = Schema.optional( + Schema.Union([StringArray, StringArrayFromJsonString]), +) + +const BooleanParam = Schema.optional(Schema.Union([Schema.Boolean, BooleanFromStringParam])) +const NumberParam = Schema.optional(Schema.Union([Schema.Number, NumberFromStringParam])) + +// apps/web/src/components/time-range-picker/search.ts +export const TimeRangeSearchFields = { + startTime: Schema.optional(Schema.String), + endTime: Schema.optional(Schema.String), + timePreset: Schema.optional(Schema.String), +} + +// apps/web/src/lib/dashboard-controls/search-params.ts +export const VARIABLE_PARAM_PREFIX = "var-" + +export const variableSearchRest = Schema.Record( + Schema.TemplateLiteral([VARIABLE_PARAM_PREFIX, Schema.String]), + Schema.Unknown, +) + +export const dashboardViewParamsSchema = { + filter: Schema.optional(Schema.String), + collapsed: Schema.optional(Schema.String), + expanded: Schema.optional(Schema.String), + tab: Schema.optional(Schema.String), + widget: Schema.optional(Schema.String), + refresh: Schema.optional(Schema.Union([Schema.Number, Schema.String])), +} + +// apps/web/src/lib/agent-sessions/tool-analytics.ts + tool-search.ts +const TOOL_METRICS = ["calls", "sessions", "error_rate", "duration"] as const +const TOOL_PERCENTILES = ["p50", "p90", "p95"] as const + +const ToolAnalyticsSearchFields = { + metric: Schema.optional(Schema.Literals(TOOL_METRICS)), + percentile: Schema.optional(Schema.Literals(TOOL_PERCENTILES)), + tool: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), + q: Schema.optional(Schema.String), + service: Schema.optional(Schema.String), + env: Schema.optional(Schema.String), + failing: BooleanParam, + error: Schema.optional(Schema.String), + session: Schema.optional(Schema.String), + variant: Schema.optional(Schema.String), +} + +// apps/web/src/components/analytics/filters.ts +const analyticsFilterSearchFields = { + host: Schema.optional(Schema.String), + pagePath: Schema.optional(Schema.String), + referrerHost: Schema.optional(Schema.String), + country: Schema.optional(Schema.String), + deviceType: Schema.optional(Schema.String), + browserName: Schema.optional(Schema.String), + osName: Schema.optional(Schema.String), + language: Schema.optional(Schema.String), + utmSource: Schema.optional(Schema.String), + utmMedium: Schema.optional(Schema.String), + utmCampaign: Schema.optional(Schema.String), + visitorType: Schema.optional(Schema.Literals(["new", "returning"])), + traffic: Schema.optional(Schema.Literals(["all", "humans", "bots"])), + eventName: Schema.optional(Schema.String), +} + +// apps/web/src/components/code-review/code-review-search.ts +const CodeReviewSearchFields = { + repo: Schema.optional(VcsRepositoryId), + author: Schema.optional(Schema.String), + ...TimeRangeSearchFields, +} + +const CodeReviewIssuesSearchFields = { + ...CodeReviewSearchFields, + severity: Schema.optional(PrReviewSeverity), + category: Schema.optional(PrReviewCategory), + state: Schema.optional(PrReviewFindingStatus), + review: Schema.optional(PrReviewId), +} + +const CodeReviewListSearchFields = { + ...CodeReviewSearchFields, + status: Schema.optional(Schema.Literals(["completed", "failed", "skipped", "running", "queued"])), + review: Schema.optional(PrReviewId), +} + +const CodeReviewCiSearchFields = { + ...CodeReviewSearchFields, + pipeline: Schema.optional(CiPipelineName), + branch: Schema.optional(CiBranchName), + result: Schema.optional(CiRunResult), +} + +// apps/web/src/components/infra/cloudflare/filters.ts + list-model.ts +const cloudflareFilterSearchFields = { + hosts: OptionalStringArrayParam, + cacheStatuses: OptionalStringArrayParam, + statusClasses: OptionalStringArrayParam, + paths: OptionalStringArrayParam, + pathContains: Schema.optional(Schema.String), + countries: OptionalStringArrayParam, + methods: OptionalStringArrayParam, + protocols: OptionalStringArrayParam, + deviceTypes: OptionalStringArrayParam, +} + +const CLOUDFLARE_VIEWS = ["zones", "workers", "queues"] as const +const ZONE_SCOPES = ["serving5xx", "mostly4xx", "lowVolume"] as const +const WORKER_SCOPES = ["erroring", "slow", "cpuHeavy"] as const +const ZONE_TABS = ["traffic", "dns", "security"] as const + +// apps/web/src/components/infra/planetscale/filters.ts +const planetscaleFilterSearchFields = { + branches: OptionalStringArrayParam, + branchStates: OptionalStringArrayParam, + branchRoles: OptionalStringArrayParam, + branchContains: Schema.optional(Schema.String), +} + +// apps/web/src/lib/errors/hub-params.ts +const HUB_VIEWS = ["open", "closed", "all"] as const +const LEGACY_HUB_VIEWS = ["triage", "active", "resolved"] as const +const HUB_SORTS = ["last_seen", "volume", "severity"] as const +const SEVERITY_FILTERS = ["all", "critical", "high", "medium", "low", "unset"] as const + +// Tab sets from account-nav.tsx, settings-nav.tsx (+ settings.tsx legacy), issue-tabs.tsx, investigation-tabs.tsx +const accountTabValues = [ + "profile", + "emails", + "password", + "two-factor", + "passkeys", + "connections", + "sessions", +] as const + +const settingsTabValues = [ + "organization", + "members", + "billing", + "audit-log", + "ingestion", + "data-platform", + "setup-audit", + "notifications", + "automation", + "api-keys", + "mcp", + "ai-provider", +] as const +const legacyTabValues = ["connectors", "integrations", "escalations", "ai", "developer"] as const + +const ISSUE_TABS = ["overview", "occurrences", "activity"] as const +const INVESTIGATION_TABS = ["overview", "evidence", "chat", "transcript"] as const + +const SortDirParam = Schema.optional(Schema.Literals(["asc", "desc"])) +const ContainsMatchMode = Schema.optional(Schema.Literals(["contains"])) +const WorkloadKindLiteral = Schema.Literals(["deployment", "statefulset", "daemonset", "job"]) + +// No validateSearch in legacy: `beforeLoad` reads the raw values. +export const ErrorIssuesSearch = Schema.Struct({ + workflowState: Schema.optional(Schema.Unknown), + severity: Schema.optional(Schema.Unknown), +}) + +// Top-level routes + +// sessionTimeRangeSearchMiddleware() +export const HomeSearch = Schema.Struct({ + environment: Schema.optional(Schema.String), + ...TimeRangeSearchFields, +}) + +export const AccountSearch = Schema.Struct({ + tab: Schema.optional(Schema.Literals(accountTabValues)), +}) + +export const ChatSearch = Schema.Struct({ + tab: Schema.optional(Schema.String), + mode: Schema.optional(Schema.Literals(["alert", "widget-fix"])), + alert: Schema.optional(Schema.String), + widget: Schema.optional(Schema.String), + shared: Schema.optional(Schema.String), + title: Schema.optional(Schema.String), + m: Schema.optional(Schema.String), +}) + +export const CliLoginSearch = Schema.Struct({ + user_code: Schema.optional(Schema.String), +}) + +// Legacy validateSearch is a function keeping `tab` only when it is a string; beforeLoad redirects to /integrations. +// Legacy validates with a plain function that maps a non-string `tab` to undefined. +export const ConnectorsSearch = Schema.Struct({ + tab: Schema.optional(Schema.Unknown), +}) + +// Legacy validateSearch is a function keeping `tab` only when it is a string; beforeLoad redirects to /settings. +export const DeveloperSearch = Schema.Struct({ + tab: Schema.optional(Schema.Unknown), +}) + +export const IntegrationsSearch = Schema.Struct({ + integration: Schema.optional(Schema.String), + chat: Schema.optional(Schema.String), + chat_reason: Schema.optional(Schema.String), + chat_workspace: Schema.optional(Schema.String), + chat_identity: Schema.optional(Schema.String), + chat_identity_name: Schema.optional(Schema.String), +}) + +export const McpAuthorizeSearch = Schema.Struct({ + request_id: Schema.optional(Schema.String), +}) + +export const OrgRequiredSearch = Schema.Struct({ + redirect_url: Schema.optional(Schema.String), +}) + +export const QuickStartSearch = Schema.Struct({ + redirect_url: Schema.optional(Schema.String), + checkout: Schema.optional(Schema.Literal("complete")), +}) + +export const SelectPlanSearch = Schema.Struct({ + redirect_url: Schema.optional(Schema.String), + checkout: Schema.optional(Schema.Literal("complete")), +}) + +// sessionTimeRangeSearchMiddleware({ maxRangeSeconds: one year }) +export const ServiceMapSearch = Schema.Struct({ + view: Schema.optional(Schema.Literals(["2d", "3d"])), + environment: Schema.optional(Schema.String), + focusService: Schema.optional(Schema.String), + focusHops: Schema.optional(Schema.Literals([1, 2])), + focusMode: Schema.optional(Schema.Literals(["dim", "hide"])), + ...TimeRangeSearchFields, +}) + +export const SettingsSearch = Schema.Struct({ + tab: Schema.optional(Schema.Literals([...settingsTabValues, ...legacyTabValues])), + checkout: Schema.optional(Schema.Literal("complete")), +}) + +export const SignInSearch = Schema.Struct({ + redirect_url: Schema.optional(Schema.String), +}) + +export const SignUpSearch = Schema.Struct({ + redirect_url: Schema.optional(Schema.String), +}) + +export const UnsubscribeSearch = Schema.Struct({ + token: Schema.optional(Schema.String), +}) + +// Detail routes + +export const AgentSessionSearch = Schema.Struct({ + t: Schema.optional(Schema.String), + end: Schema.optional(Schema.String), + view: Schema.optional(Schema.String), + span: Schema.optional(Schema.String.check(Schema.isMinLength(1), Schema.isTrimmed())), + tool: Schema.optional(Schema.String.check(Schema.isMinLength(1), Schema.isTrimmed())), +}) + +// sessionTimeRangeSearchMiddleware({ maxRangeSeconds: one year }) +export const AlertRuleSearch = Schema.Struct({ + tab: Schema.optional(Schema.String), + ...TimeRangeSearchFields, +}) + +export const AlertsCreateSearch = Schema.Struct({ + serviceName: Schema.optional(Schema.String), + ruleId: Schema.optional(Schema.String), + template: Schema.optional(Schema.String), + dashboardId: Schema.optional(Schema.String), + widgetId: Schema.optional(Schema.String), + chart: Schema.optional(Schema.String), +}) + +export const CodeReviewCiSearch = Schema.Struct(CodeReviewCiSearchFields) + +export const CodeReviewIssuesSearch = Schema.Struct(CodeReviewIssuesSearchFields) + +export const CodeReviewPullRequestsSearch = Schema.Struct(CodeReviewListSearchFields) + +export const DashboardSearch = Schema.StructWithRest( + Schema.Struct({ + mode: Schema.optional(Schema.Literal("edit")), + ...dashboardViewParamsSchema, + }), + [variableSearchRest], +) + +export const DashboardsTemplatesSearch = Schema.Struct({ + template: Schema.optional(Schema.String), +}) + +export const InvestigationSearch = Schema.Struct({ + tab: Schema.optional(Schema.Literals(INVESTIGATION_TABS)), + action: Schema.optional(Schema.Unknown), +}) + +// sessionTimeRangeSearchMiddleware() +export const MetricSearch = Schema.Struct({ + type: Schema.optional(Schema.Literals(QUERY_BUILDER_METRIC_TYPES)), + agg: Schema.optional(Schema.Literals(["avg", "sum", "min", "max", "count", "rate", "increase"])), + where: Schema.optional(Schema.String), + groupBy: Schema.optional(Schema.String), + step: Schema.optional(Schema.String), + bd: Schema.optional(Schema.String), + ...TimeRangeSearchFields, +}) + +// sessionTimeRangeSearchMiddleware({ maxRangeSeconds: one year }) +export const ReleaseSearch = Schema.Struct({ + service: Schema.optional(Schema.String), + environments: OptionalStringArrayParam, + ...TimeRangeSearchFields, +}) + +export const ReplaySearch = Schema.Struct({ + t: Schema.optional(Schema.String), +}) + +// sessionTimeRangeSearchMiddleware({ maxRangeSeconds: one year }) +export const ServiceSearch = Schema.Struct({ + tab: Schema.optional(Schema.String), + environments: OptionalStringArrayParam, + ...TimeRangeSearchFields, +}) + +export const ShareSearch = Schema.StructWithRest( + Schema.Struct({ + embed: Schema.optional(Schema.Boolean), + theme: Schema.optional(Schema.String), + from: Schema.optional(Schema.String), + to: Schema.optional(Schema.String), + range: Schema.optional(Schema.String), + refresh: Schema.optional(Schema.Union([Schema.Number, Schema.String])), + }), + [variableSearchRest], +) + +export const TraceSearch = Schema.Struct({ + spanId: Schema.optional(Schema.String), + t: Schema.optional(Schema.String), + view: Schema.optional(Schema.Literals(["waterfall", "timeline", "flow"])), +}) + +// List routes + +export const AgentSessionsSearch = Schema.Struct({ + vendors: OptionalStringArrayParam, + services: OptionalStringArrayParam, + environments: OptionalStringArrayParam, + models: OptionalStringArrayParam, + agents: OptionalStringArrayParam, + tools: OptionalStringArrayParam, + q: Schema.optional(Schema.String), + hasErrors: BooleanParam, + grouped: BooleanParam, + durationMin: NumberParam, + durationMax: NumberParam, + costMin: NumberParam, + costMax: NumberParam, + tokensMin: NumberParam, + tokensMax: NumberParam, + llmCallsMin: NumberParam, + llmCallsMax: NumberParam, + toolCallsMin: NumberParam, + toolCallsMax: NumberParam, + sortBy: Schema.optional(AiSessionSortKey), + sortDir: Schema.optional(AiSessionSortDir), +}) + +export const AlertsSearch = Schema.Struct({ + tab: Schema.optional(Schema.String), + serviceName: Schema.optional(Schema.String), + status: Schema.optional(Schema.String), +}) + +// sessionTimeRangeSearchMiddleware() +export const AnalyticsSearch = Schema.Struct({ + ...analyticsFilterSearchFields, + ...TimeRangeSearchFields, + tab: Schema.optional(Schema.String), +}) + +const AnomalySeverityLiteral = Schema.Literals(["warning", "critical"]) +const AnomalySignalLiteral = Schema.Literals([ + "error_rate", + "latency_p95", + "throughput", + "error_spike", + "log_volume", +]) + +export const AnomaliesSearch = Schema.Struct({ + status: Schema.optional(Schema.Literals(["open", "resolved", "all"])), + severity: Schema.optional(Schema.Array(AnomalySeverityLiteral)), + signals: Schema.optional(Schema.Array(AnomalySignalLiteral)), + services: Schema.optional(Schema.Array(Schema.String)), + envs: Schema.optional(Schema.Array(Schema.String)), + excludedSeverity: Schema.optional(Schema.Array(AnomalySeverityLiteral)), + excludedSignals: Schema.optional(Schema.Array(AnomalySignalLiteral)), + excludedServices: Schema.optional(Schema.Array(Schema.String)), + excludedEnvs: Schema.optional(Schema.Array(Schema.String)), + live: Schema.optional(Schema.Boolean), +}) + +export const CodeReviewSearch = Schema.Struct(CodeReviewSearchFields) + +export const DashboardsSearch = Schema.Struct({ + q: Schema.optional(Schema.String), + scope: Schema.optional(Schema.Literals(["all", "favorites"])), + tags: Schema.optional(Schema.Array(Schema.String)), + sort: Schema.optional(Schema.Literals(["updated", "created", "name-asc", "name-desc", "widgets"])), +}) + +export const ErrorsSearch = Schema.Struct({ + service: Schema.optional(Schema.String), + env: Schema.optional(Schema.String), + kind: Schema.optional(IssueKind), + regressed: Schema.optional(Schema.Union([Schema.Boolean, BooleanFromStringParam])), + view: Schema.optional(Schema.Literals([...HUB_VIEWS, ...LEGACY_HUB_VIEWS])), + q: Schema.optional(Schema.String), + sort: Schema.optional(Schema.Literals(HUB_SORTS)), + severity: Schema.optional(Schema.Literals(SEVERITY_FILTERS)), +}) + +// sessionTimeRangeSearchMiddleware() +export const InfraSearch = Schema.Struct({ + ...TimeRangeSearchFields, +}) + +export const InvestigationsSearch = Schema.Struct({ + view: Schema.optional(Schema.Literals(["active", "history"])), + kind: Schema.optional(Schema.Literals(["alert", "error", "anomaly", "question", "verification"])), + sort: Schema.optional(Schema.Literals(["updated", "severity", "confidence"])), + dir: Schema.optional(Schema.Literals(["asc", "desc"])), + q: Schema.optional(Schema.String), +}) + +// sessionTimeRangeSearchMiddleware() +export const LogsSearch = Schema.Struct({ + services: OptionalStringArrayParam, + severities: OptionalStringArrayParam, + deploymentEnvs: OptionalStringArrayParam, + deploymentEnvMatchMode: Schema.optional(Schema.Literals(["contains"])), + namespaces: OptionalStringArrayParam, + namespaceMatchMode: Schema.optional(Schema.Literals(["contains"])), + excludedServices: OptionalStringArrayParam, + excludedSeverities: OptionalStringArrayParam, + excludedDeploymentEnvs: OptionalStringArrayParam, + excludedNamespaces: OptionalStringArrayParam, + attrs: OptionalStringArrayParam, + columns: OptionalStringArrayParam, + search: Schema.optional(Schema.String), + traceId: Schema.optional(Schema.String), + refresh: dashboardViewParamsSchema.refresh, + ...TimeRangeSearchFields, +}) + +// sessionTimeRangeSearchMiddleware() +export const MetricsSearch = Schema.Struct({ + q: Schema.optional(Schema.String), + type: Schema.optional(Schema.Literals(QUERY_BUILDER_METRIC_TYPES)), + view: Schema.optional(Schema.Literals(["grid", "table"])), + ...TimeRangeSearchFields, +}) + +// sessionTimeRangeSearchMiddleware({ maxRangeSeconds: one year }) +export const ReleasesSearch = Schema.Struct({ + environments: OptionalStringArrayParam, + excludedEnvironments: OptionalStringArrayParam, + services: OptionalStringArrayParam, + impact: Schema.optional(Schema.Literals(["regressed", "watch", "rolling", "healthy"])), + ...TimeRangeSearchFields, +}) + +// sessionTimeRangeSearchMiddleware() +export const ReplaysSearch = Schema.Struct({ + service: Schema.optional(Schema.String), + browser: Schema.optional(Schema.String), + country: Schema.optional(Schema.String), + deviceType: Schema.optional(Schema.String), + userId: Schema.optional(Schema.String), + user: Schema.optional(Schema.String), + group: Schema.optional(Schema.String), + visitorId: Schema.optional(Schema.String), + hasErrors: Schema.optional(Schema.Union([Schema.Boolean, BooleanFromStringParam])), + durationMin: Schema.optional(Schema.Union([Schema.Number, NumberFromStringParam])), + durationMax: Schema.optional(Schema.Union([Schema.Number, NumberFromStringParam])), + activeMin: Schema.optional(Schema.Union([Schema.Number, NumberFromStringParam])), + activeMax: Schema.optional(Schema.Union([Schema.Number, NumberFromStringParam])), + q: Schema.optional(Schema.String), + page: Schema.optional(Schema.String), + tags: OptionalStringArrayParam, + ...TimeRangeSearchFields, +}) + +// sessionTimeRangeSearchMiddleware({ maxRangeSeconds: one year }) +export const ServicesSearch = Schema.Struct({ + environments: OptionalStringArrayParam, + namespaces: OptionalStringArrayParam, + commitShas: OptionalStringArrayParam, + excludedEnvironments: OptionalStringArrayParam, + excludedNamespaces: OptionalStringArrayParam, + excludedCommitShas: OptionalStringArrayParam, + health: Schema.optional(Schema.Literals(["healthy", "degraded", "unhealthy"])), + groupBy: Schema.optional(Schema.Literals(["namespace", "environment"])), + ...TimeRangeSearchFields, +}) + +const attributeFilterParamFields = { + key: Schema.String, + value: Schema.String, + matchMode: Schema.optional(Schema.Literals(["contains", "exists", "gt", "gte", "lt", "lte"])), + negated: Schema.optional(Schema.Union([Schema.Boolean, BooleanFromStringParam])), +} + +const AttributeFilterParam = Schema.Struct({ + ...attributeFilterParamFields, + or: Schema.optional(Schema.Array(Schema.Struct(attributeFilterParamFields))), +}) + +// sessionTimeRangeSearchMiddleware() +export const TracesSearch = Schema.Struct({ + services: OptionalStringArrayParam, + spanNames: OptionalStringArrayParam, + hasError: Schema.optional(Schema.Union([Schema.Boolean, BooleanFromStringParam])), + minDurationMs: Schema.optional(Schema.Union([Schema.Number, Schema.NumberFromString])), + maxDurationMs: Schema.optional(Schema.Union([Schema.Number, Schema.NumberFromString])), + httpMethods: OptionalStringArrayParam, + httpStatusCodes: OptionalStringArrayParam, + deploymentEnvs: OptionalStringArrayParam, + namespaces: OptionalStringArrayParam, + rootOnly: Schema.optional(Schema.Union([Schema.Boolean, BooleanFromStringParam])), + hideNoise: Schema.optional(Schema.Union([Schema.Boolean, BooleanFromStringParam])), + minSpanCount: Schema.optional(Schema.Union([Schema.Number, Schema.NumberFromString])), + whereClause: Schema.optional(Schema.String), + attributeFilters: Schema.optional(Schema.Array(AttributeFilterParam)), + resourceAttributeFilters: Schema.optional(Schema.Array(AttributeFilterParam)), + peek: Schema.optional(Schema.String), + peekRow: Schema.optional(Schema.String), + peekT: Schema.optional(Schema.String), + peekSpan: Schema.optional(Schema.String), + serviceMatchMode: ContainsMatchMode, + spanNameMatchMode: ContainsMatchMode, + deploymentEnvMatchMode: ContainsMatchMode, + namespaceMatchMode: ContainsMatchMode, + excludedServices: OptionalStringArrayParam, + excludedSpanNames: OptionalStringArrayParam, + excludedDeploymentEnvs: OptionalStringArrayParam, + excludedNamespaces: OptionalStringArrayParam, + excludedHttpMethods: OptionalStringArrayParam, + excludedHttpStatusCodes: OptionalStringArrayParam, + sortBy: Schema.optional(Schema.Literals(["timestamp", "durationMs"])), + sortDir: SortDirParam, + ...TimeRangeSearchFields, +}) + +// Nested detail routes + +// sessionTimeRangeSearchMiddleware() +export const AgentSessionToolSearch = Schema.Struct({ + ...ToolAnalyticsSearchFields, + ...TimeRangeSearchFields, +}) + +export const AlertIncidentSearch = Schema.Struct({ + alert: Schema.optional(Schema.String), +}) + +// sessionTimeRangeSearchMiddleware() +export const ErrorIssueSearch = Schema.Struct({ + tab: Schema.optional(Schema.Literals(ISSUE_TABS)), + ...TimeRangeSearchFields, +}) + +// sessionTimeRangeSearchMiddleware() +export const InfraCloudflareZoneSearch = Schema.Struct({ + ...cloudflareFilterSearchFields, + ...TimeRangeSearchFields, + tab: Schema.optional(Schema.Literals(ZONE_TABS)), +}) + +// sessionTimeRangeSearchMiddleware() +export const InfraContainerSearch = Schema.Struct({ + host: Schema.optional(Schema.String), + ...TimeRangeSearchFields, +}) + +// sessionTimeRangeSearchMiddleware() +export const InfraHostSearch = Schema.Struct({ + ...TimeRangeSearchFields, +}) + +// sessionTimeRangeSearchMiddleware() +export const InfraPlanetscaleDatabaseSearch = Schema.Struct({ + branch: Schema.optional(Schema.String), + ...planetscaleFilterSearchFields, + ...TimeRangeSearchFields, +}) + +// sessionTimeRangeSearchMiddleware() +export const InfraRailwayServiceSearch = Schema.Struct({ + ...TimeRangeSearchFields, + environmentId: Schema.String, +}) + +// Nested list routes + +// sessionTimeRangeSearchMiddleware() +export const AgentSessionToolsSearch = Schema.Struct({ + ...ToolAnalyticsSearchFields, + ...TimeRangeSearchFields, +}) + +// sessionTimeRangeSearchMiddleware() +export const InfraCloudflareSearch = Schema.Struct({ + ...TimeRangeSearchFields, + view: Schema.optional(Schema.Literals(CLOUDFLARE_VIEWS)), + q: Schema.optional(Schema.String), + scope: Schema.optional(Schema.Literals([...ZONE_SCOPES, ...WORKER_SCOPES])), + previews: Schema.optional(Schema.Boolean), + account: Schema.optional(Schema.String), +}) + +// sessionTimeRangeSearchMiddleware() +export const InfraContainersSearch = Schema.Struct({ + q: Schema.optional(Schema.String), + scope: Schema.optional(Schema.Literals(["saturated", "elevated", "stale"])), + sortBy: Schema.optional( + Schema.Literals(["saturation", "cpuPct", "memoryPct", "containerName", "lastSeen"]), + ), + sortDir: SortDirParam, + containerNames: OptionalStringArrayParam, + hostNames: OptionalStringArrayParam, + images: OptionalStringArrayParam, + composeProjects: OptionalStringArrayParam, + composeServices: OptionalStringArrayParam, + environments: OptionalStringArrayParam, + excludedContainerNames: OptionalStringArrayParam, + excludedHostNames: OptionalStringArrayParam, + excludedImages: OptionalStringArrayParam, + excludedComposeProjects: OptionalStringArrayParam, + excludedComposeServices: OptionalStringArrayParam, + excludedEnvironments: OptionalStringArrayParam, + ...TimeRangeSearchFields, +}) + +// sessionTimeRangeSearchMiddleware() +export const InfraGcpSearch = Schema.Struct({ + view: Schema.optional(Schema.String), + tab: Schema.optional(Schema.String), + scope: Schema.optional(Schema.String), + project: Schema.optional(Schema.String), + region: Schema.optional(Schema.String), + q: Schema.optional(Schema.String), + type: Schema.optional(Schema.String), + ...TimeRangeSearchFields, +}) + +// sessionTimeRangeSearchMiddleware() +export const InfraHostsSearch = Schema.Struct({ + q: Schema.optional(Schema.String), + scope: Schema.optional(Schema.Literals(["saturated", "elevated", "stale"])), + ...TimeRangeSearchFields, +}) + +// sessionTimeRangeSearchMiddleware(); beforeLoad redirects to /infra/kubernetes/pods carrying search. +export const InfraKubernetesSearch = Schema.Struct(TimeRangeSearchFields) + +// sessionTimeRangeSearchMiddleware() +export const InfraPlanetscaleSearch = Schema.Struct({ + ...TimeRangeSearchFields, +}) + +// sessionTimeRangeSearchMiddleware() +export const InfraRailwaySearch = Schema.Struct({ + q: Schema.optional(Schema.String), + ...TimeRangeSearchFields, +}) + +// Deeper routes + +export const DashboardWidgetSearch = Schema.StructWithRest(Schema.Struct(dashboardViewParamsSchema), [ + variableSearchRest, +]) + +// sessionTimeRangeSearchMiddleware() +export const InfraCloudflareWorkerSearch = Schema.Struct({ + ...TimeRangeSearchFields, + account: Schema.optional(Schema.String), +}) + +// sessionTimeRangeSearchMiddleware() +export const InfraGcpResourceSearch = Schema.Struct({ + project: Schema.optional(Schema.String), + region: Schema.optional(Schema.String), + namespace: Schema.optional(Schema.String), + cluster: Schema.optional(Schema.String), + location: Schema.optional(Schema.String), + zone: Schema.optional(Schema.String), + backend: Schema.optional(Schema.String), + ...TimeRangeSearchFields, +}) + +// sessionTimeRangeSearchMiddleware() +export const InfraKubernetesNodeSearch = Schema.Struct(TimeRangeSearchFields) + +// sessionTimeRangeSearchMiddleware() +export const InfraKubernetesPodSearch = Schema.Struct({ + namespace: Schema.optional(Schema.String), + ...TimeRangeSearchFields, +}) + +// sessionTimeRangeSearchMiddleware() +export const InfraKubernetesServiceSearch = Schema.Struct(TimeRangeSearchFields) + +// sessionTimeRangeSearchMiddleware() +export const InfraKubernetesNodesSearch = Schema.Struct({ + q: Schema.optional(Schema.String), + status: Schema.optional(Schema.Literals(["active", "idle", "ended"])), + nodeNames: OptionalStringArrayParam, + clusters: OptionalStringArrayParam, + environments: OptionalStringArrayParam, + ...TimeRangeSearchFields, +}) + +// sessionTimeRangeSearchMiddleware() +export const InfraKubernetesPodsSearch = Schema.Struct({ + q: Schema.optional(Schema.String), + scope: Schema.optional(Schema.Literals(["saturated", "elevated", "unbounded", "ended"])), + sortBy: Schema.optional( + Schema.Literals(["saturation", "cpuUsage", "cpuLimitPct", "memoryLimitPct", "podName", "lastSeen"]), + ), + sortDir: SortDirParam, + peek: Schema.optional(Schema.String), + podNames: OptionalStringArrayParam, + namespaces: OptionalStringArrayParam, + nodeNames: OptionalStringArrayParam, + clusters: OptionalStringArrayParam, + deployments: OptionalStringArrayParam, + statefulsets: OptionalStringArrayParam, + daemonsets: OptionalStringArrayParam, + jobs: OptionalStringArrayParam, + environments: OptionalStringArrayParam, + computeTypes: OptionalStringArrayParam, + excludedPodNames: OptionalStringArrayParam, + excludedNamespaces: OptionalStringArrayParam, + excludedNodeNames: OptionalStringArrayParam, + excludedClusters: OptionalStringArrayParam, + excludedDeployments: OptionalStringArrayParam, + excludedStatefulsets: OptionalStringArrayParam, + excludedDaemonsets: OptionalStringArrayParam, + excludedJobs: OptionalStringArrayParam, + excludedEnvironments: OptionalStringArrayParam, + excludedComputeTypes: OptionalStringArrayParam, + ...TimeRangeSearchFields, +}) + +// sessionTimeRangeSearchMiddleware() +export const InfraKubernetesServicesSearch = Schema.Struct(TimeRangeSearchFields) + +// sessionTimeRangeSearchMiddleware() +export const InfraKubernetesWorkloadsSearch = Schema.Struct({ + kind: Schema.optional(WorkloadKindLiteral), + q: Schema.optional(Schema.String), + scope: Schema.optional(Schema.Literals(["saturated", "elevated", "stale"])), + workloadNames: OptionalStringArrayParam, + namespaces: OptionalStringArrayParam, + clusters: OptionalStringArrayParam, + environments: OptionalStringArrayParam, + computeTypes: OptionalStringArrayParam, + ...TimeRangeSearchFields, +}) + +// sessionTimeRangeSearchMiddleware(); params.parse decodes `kind` against the workload-kind literals. +export const InfraKubernetesWorkloadSearch = Schema.Struct({ + namespace: Schema.optional(Schema.String), + ...TimeRangeSearchFields, +}) + +// Path params of /infra/kubernetes/workloads/$kind/$workloadName (legacy `params.parse`). +export const InfraKubernetesWorkloadParams = Schema.Struct({ + kind: WorkloadKindLiteral, + workloadName: Schema.String, +}) diff --git a/apps/web-foldkit/src/route/segment.ts b/apps/web-foldkit/src/route/segment.ts new file mode 100644 index 0000000000..18cd3d19d4 --- /dev/null +++ b/apps/web-foldkit/src/route/segment.ts @@ -0,0 +1,25 @@ +import { Effect, pipe, Schema, SchemaIssue, SchemaTransformation } from "effect" +import { literal, schemaSegment, slash, type Biparser } from "foldkit/route" + +/** A URL segment, percent-decoded and re-encoded the way TanStack Router treats `$param`. */ +const PercentEncoded = Schema.String.pipe( + Schema.decodeTo( + Schema.String, + SchemaTransformation.transformEffect({ + decode: (raw, options) => + Effect.try({ + try: () => decodeURIComponent(raw), + catch: () => new SchemaIssue.InvalidValue({ message: "malformed percent-encoding" }, raw, options), + }), + encode: (value) => Effect.succeed(encodeURIComponent(value)), + }), + ), +) + +/** A decoded path param. Foldkit's `string` keeps the raw segment (`checkout%20api`). */ +export const segment = (name: K): Biparser> => + schemaSegment(name, PercentEncoded) + +/** `literal` segments joined by `slash`: `path("infra", "kubernetes")`. */ +export const path = (first: string, ...rest: ReadonlyArray): Biparser<{}> => + rest.reduce((parser, next) => pipe(parser, slash(literal(next))), literal(first)) diff --git a/apps/web-foldkit/src/route/tanstack-search.ts b/apps/web-foldkit/src/route/tanstack-search.ts new file mode 100644 index 0000000000..fb02849615 --- /dev/null +++ b/apps/web-foldkit/src/route/tanstack-search.ts @@ -0,0 +1,45 @@ +import { Option, Predicate, Record, Schema, SchemaGetter } from "effect" + +const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Json)) + +// TanStack Router's default search format: each value is JSON when it parses as JSON, raw +// otherwise, and a string that would parse as JSON is written quoted. Same URLs both ways. + +const parseValue = (raw: string): Schema.Json => + Option.getOrElse(decodeJson(raw), () => raw) + +const isJson = (raw: string): boolean => Option.isSome(decodeJson(raw)) + +const stringifyValue = (value: unknown): Option.Option => { + if (value === undefined) { + return Option.none() + } + + if (Predicate.isString(value)) { + return Option.some(isJson(value) ? JSON.stringify(value) : value) + } + + return Option.some(JSON.stringify(value)) +} + +const parseSearch = (record: Readonly>) => ({ search: Record.map(record, parseValue) }) + +const stringifySearch = (decoded: unknown): Record => + Predicate.isObject(decoded) && "search" in decoded && Predicate.isObject(decoded.search) + ? Record.getSomes(Record.map(Record.fromEntries(Object.entries(decoded.search)), stringifyValue)) + : {} + +const SearchRecord = Schema.Record(Schema.String, Schema.String) + +/** The query codec of a route: the legacy `validateSearch` schema, carried as the route's `search`. */ +export const tanstackSearch = (schema: Schema.Codec) => + SearchRecord.pipe( + Schema.decodeTo(Schema.Unknown, { + decode: SchemaGetter.transform(parseSearch), + encode: SchemaGetter.transform(stringifySearch), + }), + Schema.decodeTo(Schema.Struct({ search: schema })), + ) + +/** A route with no `validateSearch` drops whatever query it was given, like TanStack's empty search. */ +export const noSearch = Schema.Struct({}) diff --git a/apps/web-foldkit/src/styles.css b/apps/web-foldkit/src/styles.css new file mode 100644 index 0000000000..22c5178d2b --- /dev/null +++ b/apps/web-foldkit/src/styles.css @@ -0,0 +1,8 @@ +/* The same stylesheets as apps/web, imported in place so class strings render identically. */ +@import "../../web/src/styles.css"; +@import "../../web/src/components/layout/account-layout.css"; + +/* apps/web's utilities come from scanning its own sources; scan them too so both builds emit the + same rules, plus this app's views. */ +@source "../../web/src"; +@source "./"; diff --git a/apps/web-foldkit/src/subscription.ts b/apps/web-foldkit/src/subscription.ts new file mode 100644 index 0000000000..b7ce40abe4 --- /dev/null +++ b/apps/web-foldkit/src/subscription.ts @@ -0,0 +1,40 @@ +import { isClerkAuthEnabled } from "@maple/web/lib/services/common/auth-mode" +import { Effect, Schema, Stream } from "effect" +import { Dom, Subscription } from "foldkit" +import { Message } from "./message" +import type { Model } from "./model" +import { SELF_HOSTED_AUTH_EVENT } from "./platform/auth-api" +import { clerkSnapshots } from "./platform/clerk" + +const selfHostedAuthTarget: Dom.TypedEventTarget<{ [SELF_HOSTED_AUTH_EVENT]: Event }> = window + +export const subscriptions = Subscription.make()((entry) => ({ + /** `subscribeSelfHostedAuthChanges`: a renewal elsewhere ended or replaced the session. */ + selfHostedSession: entry( + { isSelfHosted: Schema.Boolean }, + { + modelToDependencies: () => ({ isSelfHosted: !isClerkAuthEnabled }), + dependenciesToStream: ({ isSelfHosted }) => + Stream.when( + Dom.streamFromEvent({ + target: selfHostedAuthTarget, + type: SELF_HOSTED_AUTH_EVENT, + mapEvent: () => Message.ChangedSelfHostedSession(), + }), + Effect.sync(() => isSelfHosted), + ), + }, + ), + /** Clerk's listener (what `useAuth()` re-renders on), once clerk-js has loaded. */ + clerkSession: entry( + { isClerkLoaded: Schema.Boolean }, + { + modelToDependencies: (model) => ({ isClerkLoaded: model.clerk._tag === "Loaded" }), + dependenciesToStream: ({ isClerkLoaded }) => + Stream.when( + Stream.map(clerkSnapshots, (snapshot) => Message.ChangedClerkSession({ snapshot })), + Effect.sync(() => isClerkLoaded), + ), + }, + ), +})) diff --git a/apps/web-foldkit/src/ui/button.ts b/apps/web-foldkit/src/ui/button.ts new file mode 100644 index 0000000000..9d31f99130 --- /dev/null +++ b/apps/web-foldkit/src/ui/button.ts @@ -0,0 +1,85 @@ +import { cn } from "@maple/ui/lib/utils" +import { cva, type VariantProps } from "class-variance-authority" +import type { Attribute, Html, HtmlBuilder } from "foldkit/html" +import { loaderIcon } from "./icons" + +// Copied verbatim from packages/ui/src/components/ui/button.tsx, which imports React. +// Phase 1 moves it to a shared framework-free button.styles.ts. +export const buttonVariants = cva( + "relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-lg border font-medium text-base outline-none transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 data-loading:select-none data-loading:text-transparent sm:text-sm [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg]:pointer-events-none [&_svg]:-mx-0.5 [&_svg]:shrink-0", + { + defaultVariants: { + size: "default", + variant: "default", + }, + variants: { + size: { + default: "h-9 px-[calc(--spacing(3)-1px)] sm:h-8 [&_svg:not([class*='size-'])]:size-3.5", + icon: "size-9 sm:size-8 [&_svg:not([class*='size-'])]:size-3.5", + "icon-lg": "size-10 sm:size-9 [&_svg:not([class*='size-'])]:size-4", + "icon-sm": "size-8 sm:size-7 [&_svg:not([class*='size-'])]:size-3.5", + "icon-xl": + "size-11 sm:size-10 [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5", + "icon-2xs": + "size-4 rounded-sm before:rounded-[calc(var(--radius-sm)-1px)] [&_svg:not([class*='size-'])]:size-3", + "icon-xs": + "size-7 rounded-md before:rounded-[calc(var(--radius-md)-1px)] sm:size-6 not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-3", + lg: "h-10 px-[calc(--spacing(3.5)-1px)] sm:h-9 [&_svg:not([class*='size-'])]:size-4", + sm: "h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] sm:h-7 [&_svg:not([class*='size-'])]:size-3.5", + xl: "h-11 px-[calc(--spacing(4)-1px)] text-lg sm:h-10 sm:text-base [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5", + xs: "h-7 gap-1 rounded-md px-[calc(--spacing(2)-1px)] text-sm before:rounded-[calc(var(--radius-md)-1px)] sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-3", + }, + variant: { + default: + "not-disabled:inset-shadow-[0_1px_--theme(--color-white/16%)] border-primary bg-primary text-primary-foreground shadow-primary/24 shadow-xs hover:bg-primary/90 data-pressed:bg-primary/90 *:data-[slot=button-loading-indicator]:text-primary-foreground [:active,[data-pressed]]:inset-shadow-[0_1px_--theme(--color-black/8%)] [:disabled,:active,[data-pressed]]:shadow-none", + destructive: + "not-disabled:inset-shadow-[0_1px_--theme(--color-white/16%)] border-destructive bg-destructive text-white shadow-destructive/24 shadow-xs hover:bg-destructive/90 data-pressed:bg-destructive/90 *:data-[slot=button-loading-indicator]:text-white [:active,[data-pressed]]:inset-shadow-[0_1px_--theme(--color-black/8%)] [:disabled,:active,[data-pressed]]:shadow-none", + "destructive-outline": + "border-input bg-popover not-dark:bg-clip-padding text-destructive-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] hover:border-destructive/32 hover:bg-destructive/4 data-pressed:border-destructive/32 data-pressed:bg-destructive/4 *:data-[slot=button-loading-indicator]:text-foreground dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none", + ghost: "border-transparent text-foreground hover:bg-accent data-pressed:bg-accent *:data-[slot=button-loading-indicator]:text-foreground", + link: "border-transparent text-foreground underline-offset-4 hover:underline data-pressed:underline *:data-[slot=button-loading-indicator]:text-foreground", + outline: + "border-input bg-popover not-dark:bg-clip-padding text-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] hover:bg-accent/50 data-pressed:bg-accent/50 *:data-[slot=button-loading-indicator]:text-foreground dark:bg-input/32 dark:data-pressed:bg-input/64 dark:hover:bg-input/64 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/90 data-pressed:bg-secondary/90 *:data-[slot=button-loading-indicator]:text-secondary-foreground [:active,[data-pressed]]:bg-secondary/80", + }, + }, + }, +) + +export type ButtonOptions = VariantProps & + Readonly<{ + className?: string + type?: "button" | "submit" + isLoading?: boolean + isDisabled?: boolean + attributes?: ReadonlyArray> + }> + +/** ` diff --git a/packages/ui/src/components/ui/copyable-badge.styles.ts b/packages/ui/src/components/ui/copyable-badge.styles.ts new file mode 100644 index 0000000000..75aab37cf4 --- /dev/null +++ b/packages/ui/src/components/ui/copyable-badge.styles.ts @@ -0,0 +1 @@ +export const copyableBadgeStyles = "max-w-full" diff --git a/packages/ui/src/components/ui/copyable-badge.tsx b/packages/ui/src/components/ui/copyable-badge.tsx index 93ad36f938..7c10ce73f4 100644 --- a/packages/ui/src/components/ui/copyable-badge.tsx +++ b/packages/ui/src/components/ui/copyable-badge.tsx @@ -9,6 +9,7 @@ import { cn } from "../../lib/utils" import { badgeVariants } from "./badge" import { copyTooltipText } from "./copy-button" import { Tooltip, TooltipPopup, TooltipTrigger } from "./tooltip" +import { copyableBadgeStyles } from "./copyable-badge.styles" export interface CopyableBadgeProps { /** The full value written to the clipboard (may differ from the displayed children). */ @@ -40,7 +41,7 @@ export function CopyableBadge({ render={