diff --git a/Cargo.toml b/Cargo.toml index e5f6cb9a2..a371be49e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,8 +23,8 @@ rust-version = "1.86" [workspace.dependencies] differential-dataflow = { path = "differential-dataflow", default-features = false, version = "0.23.0" } -timely = { version = "0.29", default-features = false } -columnar = { version = "0.12", default-features = false } +timely = { version = "0.30", default-features = false } +columnar = { version = "0.13", default-features = false } #timely = { git = "https://github.com/TimelyDataflow/timely-dataflow", default-features = false } #timely = { path = "../timely-dataflow/timely/", default-features = false } diff --git a/advent_of_code_2017/src/bin/day_09.rs b/advent_of_code_2017/src/bin/day_09.rs index 8a9a0f310..cfda8ed06 100644 --- a/advent_of_code_2017/src/bin/day_09.rs +++ b/advent_of_code_2017/src/bin/day_09.rs @@ -107,7 +107,7 @@ where unit_ranges .iterate(|scope, ranges| - // Each available range, of size less than usize::max_value(), advertises itself as the range + // Each available range, of size less than usize::MAX, advertises itself as the range // twice as large, aligned to integer multiples of its size. Each range, which may contain at // most two elements, then summarizes itself using the `combine` function. Finally, we re-add // the initial `unit_ranges` intervals, so that the set of ranges grows monotonically. diff --git a/diagnostics/README.md b/diagnostics/README.md new file mode 100644 index 000000000..be2145ecb --- /dev/null +++ b/diagnostics/README.md @@ -0,0 +1,75 @@ +# diagnostics + +A diagnostics crate for differential / timely dataflow programs. +Captures live operator, channel, and arrangement state into a DD +computation, exposes it over a WebSocket, and ships two browser +frontends. + +## Wiring it into a program + +```rust +use diagnostics::{logging, server::Server}; + +timely::execute_from_args(std::env::args(), move |worker| { + let state = logging::register(worker, /* log_logging */ false); + // Worker 0 owns the WebSocket server; others drop their sink so + // the dataflow's input frontiers can advance. + let _server = if worker.index() == 0 { + Some(Server::start(51371, state.sink)) + } else { + drop(state.sink); + None + }; + + // ... your computation ... +}) +``` + +See `examples/scc-bench.rs` for a complete instrumented program. + +## Viewing diagnostics + +The server prints a hint on startup. The two options: + +### Single-file (no build step) + +``` +cd diagnostics +python3 -m http.server 8000 +``` + +Open `http://localhost:8000/index.html`, click Connect. + +This is the path of least resistance — `index.html` is one file you +can scp anywhere. No tooling required. + +### Console (React + TanStack DB) + +``` +cd diagnostics/console +npm install # first time +npm run dev +``` + +Open the URL Vite prints, click Connect. The console adds incremental +filtering, a per-frame transactional commit boundary, and a +forward-looking architecture; see `console/README.md` for details. +The default port `5173` of Vite means simultaneous use with the +single-file UI on port 8000 is fine. + +Both UIs consume the same wire format and can connect to the same +running server side by side. + +## Wire format + +Each WebSocket message is one `Frame` envelope: + +```json +{ "type": "Frame", "ts_us": , "updates": [...] } +``` + +The server emits a Frame only after the dataflow's frontier has +advanced past `ts_us`, so each Frame is a transactionally complete +view at one closed logical timestamp. Update variants inside +`updates` are tagged unions for `Operator`, `Channel`, and `Stat`; +see `src/server.rs::JsonUpdate` for the schema. diff --git a/diagnostics/console/.gitignore b/diagnostics/console/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/diagnostics/console/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/diagnostics/console/README.md b/diagnostics/console/README.md new file mode 100644 index 000000000..af99b3a6e --- /dev/null +++ b/diagnostics/console/README.md @@ -0,0 +1,54 @@ +# Diagnostics console + +A React frontend for the diagnostics WebSocket server defined in this +crate. An alternative to the single-file `diagnostics/index.html`; both +consume the same wire format (`Frame` envelopes, one per closed +timestamp). + +## Running + +Start a diagnostics-instrumented program (e.g. +`cargo run --release --example scc-bench -p diagnostics -- -w4`), +then in this directory: + +``` +npm install +npm run dev +``` + +Open the URL Vite prints, click Connect (defaults to +`ws://localhost:51371`). + +For a production build: + +``` +npm run build +``` + +The output lands in `dist/` and can be served by any static file +server. + +## Layout + +``` +src/ + collections.ts TanStack DB local-only collections (operators, channels, statCounters) + ws.ts WebSocket bridge — applies one Frame as one transaction across collections + derive.ts recursive aggregates (transitive messages, descendants, sumElapsed) + graph/ + buildScopeGraph.ts pure: derived state + scope id → renderable graph + layout.ts async: chain detection + ELK layered layout + components/ + Overview.tsx sortable root-dataflow table + Detail.tsx scope graph hosting + breadcrumb + filter affordances + Graph.tsx effect-driven layout, JSX SVG render + App.tsx top-level shell: connect form, tabs, filter, scope stack +``` + +## Watermark contract + +Each WebSocket message is one `Frame` (`{ type, ts_us, updates }`), +emitted by the server only after the dataflow's frontier has advanced +past `ts_us`. The bridge applies each Frame as one TanStack DB +`createTransaction`, so every `useLiveQuery` observer sees a sequence +of consistent closed-timestamp views — never a half-applied frame. diff --git a/diagnostics/console/eslint.config.js b/diagnostics/console/eslint.config.js new file mode 100644 index 000000000..ef614d25c --- /dev/null +++ b/diagnostics/console/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/diagnostics/console/index.html b/diagnostics/console/index.html new file mode 100644 index 000000000..d4e6c8f3b --- /dev/null +++ b/diagnostics/console/index.html @@ -0,0 +1,13 @@ + + + + + + + Differential Dataflow Diagnostics + + +
+ + + diff --git a/diagnostics/console/package-lock.json b/diagnostics/console/package-lock.json new file mode 100644 index 000000000..d2f8ff9f1 --- /dev/null +++ b/diagnostics/console/package-lock.json @@ -0,0 +1,2833 @@ +{ + "name": "web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.0", + "dependencies": { + "@tanstack/db": "^0.6.5", + "@tanstack/react-db": "^0.1.83", + "elkjs": "^0.11.1", + "react": "^19.2.5", + "react-dom": "^19.2.5" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.2.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.5.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.2", + "vite": "^8.0.10" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@tanstack/db": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@tanstack/db/-/db-0.6.5.tgz", + "integrity": "sha512-gtCuAo4UtC9SR/kTMu5fVEff6qZ2R1FZi9X7MybtHKA6wve7RePifGG6qBI4OmMB+7juT5/+glNbnqZOrG0/pg==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@tanstack/db-ivm": "0.1.18", + "@tanstack/pacer-lite": "^0.2.1" + }, + "peerDependencies": { + "typescript": ">=4.7" + } + }, + "node_modules/@tanstack/db-ivm": { + "version": "0.1.18", + "resolved": "https://registry.npmjs.org/@tanstack/db-ivm/-/db-ivm-0.1.18.tgz", + "integrity": "sha512-+pZJiRKdoKRM5Epq9T7otD9ZJl82pRFauo7LKuJGrarjVKQ7r+QQlPe3kGdN9LEKSnuNGIWjX9OOY4M8kH4eLw==", + "license": "MIT", + "dependencies": { + "fractional-indexing": "^3.2.0", + "sorted-btree": "^1.8.1" + }, + "peerDependencies": { + "typescript": ">=4.7" + } + }, + "node_modules/@tanstack/pacer-lite": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.2.1.tgz", + "integrity": "sha512-3PouiFjR4B6x1c969/Pl4ZIJleof1M0n6fNX8NRiC9Sqv1g06CVDlEaXUR4212ycGFyfq4q+t8Gi37Xy+z34iQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-db": { + "version": "0.1.83", + "resolved": "https://registry.npmjs.org/@tanstack/react-db/-/react-db-0.1.83.tgz", + "integrity": "sha512-LNV0C7OARazooT2hLTr5anXo6tbEyX2rHZQ0j9HZ/iNBI+Tx/y19o5Nd3ooyAYz5LEHJJxb8iM8ZTVB/diGnXw==", + "license": "MIT", + "dependencies": { + "@tanstack/db": "0.6.5", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", + "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/type-utils": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", + "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", + "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.1", + "@typescript-eslint/types": "^8.59.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", + "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", + "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", + "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", + "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", + "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.1", + "@typescript-eslint/tsconfig-utils": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", + "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", + "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.349", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", + "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", + "dev": true, + "license": "ISC" + }, + "node_modules/elkjs": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz", + "integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==", + "license": "EPL-2.0" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", + "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fractional-indexing": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fractional-indexing/-/fractional-indexing-3.2.0.tgz", + "integrity": "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==", + "license": "CC0-1.0", + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sorted-btree": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sorted-btree/-/sorted-btree-1.8.1.tgz", + "integrity": "sha512-395+XIP+wqNn3USkFSrNz7G3Ss/MXlZEqesxvzCRFwL14h6e8LukDHdLBePn5pwbm5OQ9vGu8mDyz2lLDIqamQ==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz", + "integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.1", + "@typescript-eslint/parser": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/diagnostics/console/package.json b/diagnostics/console/package.json new file mode 100644 index 000000000..e7425a954 --- /dev/null +++ b/diagnostics/console/package.json @@ -0,0 +1,33 @@ +{ + "name": "diagnostics-console", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/db": "^0.6.5", + "@tanstack/react-db": "^0.1.83", + "elkjs": "^0.11.1", + "react": "^19.2.5", + "react-dom": "^19.2.5" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.2.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.5.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.2", + "vite": "^8.0.10" + } +} diff --git a/diagnostics/console/public/favicon.svg b/diagnostics/console/public/favicon.svg new file mode 100644 index 000000000..6893eb132 --- /dev/null +++ b/diagnostics/console/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/diagnostics/console/src/App.tsx b/diagnostics/console/src/App.tsx new file mode 100644 index 000000000..0b581adf2 --- /dev/null +++ b/diagnostics/console/src/App.tsx @@ -0,0 +1,200 @@ +import { useMemo, useRef, useState } from 'react' +import { useLiveQuery } from '@tanstack/react-db' +import { ilike } from '@tanstack/db' +import { channels, operators, statCounters } from './collections' +import { connect, type ConnState, type WsConn } from './ws' +import { derive } from './derive' +import { Overview } from './components/Overview' +import { Detail } from './components/Detail' + +type Tab = 'overview' | 'detail' + +export function App() { + const [url, setUrl] = useState('ws://localhost:51371') + const [conn, setConn] = useState(null) + const [state, setState] = useState({ kind: 'idle' }) + const [errors, setErrors] = useState([]) + const [tab, setTab] = useState('overview') + const [selectedDataflowId, setSelectedDataflowId] = useState(null) + const [scopeStack, setScopeStack] = useState([]) + const [filter, setFilter] = useState('') + const [compact, setCompact] = useState(false) + const connRef = useRef(null) + + const { data: opRows } = useLiveQuery((q) => q.from({ operators })) + const { data: chRows } = useLiveQuery((q) => q.from({ channels })) + const { data: statRows } = useLiveQuery((q) => q.from({ statCounters })) + + // Live-queried filtered subset. The `where` clause runs inside TanStack DB + // as an incremental view: when the operators collection changes, only + // delta rows are re-evaluated, and only matching/un-matching transitions + // are emitted to subscribers. This is what we point at to demonstrate the + // IVM ("zero") aspect of the data layer. We always run the same query + // shape (ilike against a pattern); the empty-filter case uses `%` which + // matches every string, so no conditional query swap is needed. + const filterTrimmed = filter.trim() + const filterActive = filterTrimmed !== '' + const filterPattern = filterActive ? `%${filterTrimmed}%` : `%` + const { data: filteredOps } = useLiveQuery( + (q) => + q + .from({ operators }) + .where(({ operators }) => ilike(operators.name, filterPattern)), + [filterPattern], + ) + const filteredIds = useMemo( + () => new Set((filteredOps ?? []).map((o) => o.id)), + [filteredOps], + ) + + const derived = useMemo( + () => derive(opRows ?? [], chRows ?? [], statRows ?? []), + [opRows, chRows, statRows], + ) + + function handleConnect() { + if (conn) { + conn.close() + connRef.current = null + setConn(null) + return + } + const c = connect( + url.trim(), + setState, + (msg) => setErrors((prev) => [...prev, msg]), + ) + connRef.current = c + setConn(c) + } + + function selectDataflow(id: number) { + setSelectedDataflowId(id) + setScopeStack([]) + setTab('detail') + } + + const statusText = + state.kind === 'idle' + ? 'Not connected' + : state.kind === 'connecting' + ? `Connecting to ${state.url}…` + : state.kind === 'open' + ? `Live (${state.updates} updates, ${derived.operators.size} operators)` + : state.kind === 'closed' + ? `Disconnected (${state.updates} updates received)` + : `Error: ${state.message}` + + const statusClass = + state.kind === 'open' ? 'loaded' : state.kind === 'error' ? 'error' : '' + + const showMain = state.kind === 'open' || state.kind === 'closed' + + return ( + <> + + +
+
+ setUrl(e.target.value)} + placeholder="ws://localhost:51371" + /> + + {showMain && ( + <> + | + + setFilter(e.target.value)} + placeholder="ilike substring (e.g. arrange)" + /> + {filterActive && ( + + {filteredIds.size} operator{filteredIds.size === 1 ? '' : 's'} match + + )} + + + )} +
+
+ + {showMain && ( +
+
+ + +
+ +
+ {tab === 'overview' && ( + + )} +
+ +
+ {tab === 'detail' && ( + + )} +
+
+ )} + + {errors.length > 0 && ( +
+ {errors.map((e, i) => ( +
{e}
+ ))} +
+ )} + + ) +} diff --git a/diagnostics/console/src/collections.ts b/diagnostics/console/src/collections.ts new file mode 100644 index 000000000..e19f41d7f --- /dev/null +++ b/diagnostics/console/src/collections.ts @@ -0,0 +1,72 @@ +import { createCollection, localOnlyCollectionOptions } from '@tanstack/db' + +// Wire-protocol mirror collections. Each row maps 1:1 to a logical entity +// derived from the diagnostics dataflow; the WebSocket bridge in `ws.ts` +// translates incoming JSON diffs into mutations on these. + +export type Operator = { + id: number + name: string + addr: number[] +} + +export type Channel = { + id: number + scope_addr: number[] + source: [number, number] + target: [number, number] +} + +export type StatKind = + | 'Elapsed' + | 'Messages' + | 'ArrangementBatches' + | 'ArrangementRecords' + | 'Sharing' + | 'BatcherRecords' + | 'BatcherSize' + | 'BatcherCapacity' + | 'BatcherAllocations' + +// Stats are accumulated counters keyed by (kind, id). The wire protocol +// sends signed diffs; we maintain a running total per key. +export type StatCounter = { + key: string // `${kind}:${id}` + kind: StatKind + id: number + value: number +} + +export const operators = createCollection( + localOnlyCollectionOptions({ + id: 'operators', + getKey: (op) => op.id, + }), +) + +export const channels = createCollection( + localOnlyCollectionOptions({ + id: 'channels', + getKey: (ch) => ch.id, + }), +) + +export const statCounters = createCollection( + localOnlyCollectionOptions({ + id: 'statCounters', + getKey: (s) => s.key, + }), +) + +export function statKey(kind: StatKind, id: number): string { + return `${kind}:${id}` +} + +export function clearAll() { + const opIds = operators.toArray.map((o) => o.id) + if (opIds.length) operators.delete(opIds) + const chIds = channels.toArray.map((c) => c.id) + if (chIds.length) channels.delete(chIds) + const sKeys = statCounters.toArray.map((s) => s.key) + if (sKeys.length) statCounters.delete(sKeys) +} diff --git a/diagnostics/console/src/components/Detail.tsx b/diagnostics/console/src/components/Detail.tsx new file mode 100644 index 000000000..3aa7234e2 --- /dev/null +++ b/diagnostics/console/src/components/Detail.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react' +import type { Derived } from '../derive' +import { addrKey, formatNs } from '../derive' +import { Graph } from './Graph' + +export function Detail({ + derived, + dataflowId, + scopeStack, + setScopeStack, + filterActive, + filteredIds, + compact, +}: { + derived: Derived + dataflowId: number | null + scopeStack: number[][] + setScopeStack: (s: number[][]) => void + filterActive: boolean + filteredIds: Set + compact: boolean +}) { + const [hideInactive, setHideInactive] = useState(false) + + if (dataflowId == null || !derived.operators.has(dataflowId)) { + return
Select a dataflow from the Overview tab
+ } + const root = derived.operators.get(dataflowId)! + const currentAddr = + scopeStack.length > 0 ? scopeStack[scopeStack.length - 1] : root.addr + const totalOps = derived.descendantCount.get(dataflowId) ?? 0 + const totalMsgs = derived.transitiveMessages.get(dataflowId) ?? 0 + const totalElapsed = derived.sumElapsed.get(dataflowId) ?? 0 + + // Map current addr back to an operator id (the scope we're rendering). + const currentScopeId = derived.addrToId.get(addrKey(currentAddr)) ?? dataflowId + + function pushScope(opId: number) { + const op = derived.operators.get(opId) + if (!op) return + setScopeStack([...scopeStack, op.addr]) + } + + return ( + <> +
+

{root.name}

+ + {totalOps} operators, {totalMsgs} records, {formatNs(totalElapsed)} + +
+
+ + + | + +
+
+ + {' '} + Scope + + + {' '} + Operator + + + {' '} + Arrangement + + + {' '} + Boundary port + +
+
+ Scope: + [{currentAddr.join(', ')}] +
+
+ +
+ + ) +} diff --git a/diagnostics/console/src/components/Graph.tsx b/diagnostics/console/src/components/Graph.tsx new file mode 100644 index 000000000..71135cf49 --- /dev/null +++ b/diagnostics/console/src/components/Graph.tsx @@ -0,0 +1,350 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import type { Derived } from '../derive' +import { buildScopeGraph, L } from '../graph/buildScopeGraph' +import type { + EdgeRoute, + LaidNode, + Layout, +} from '../graph/layout' +import { computeLayout } from '../graph/layout' + +// Render the laid-out scope graph as JSX SVG. Layout runs in an effect +// (async, ELK is non-trivial); the latest result wins if the inputs +// change while a previous run is still in flight. +export function Graph({ + derived, + scopeId, + hideInactive, + onPushScope, + filterActive, + filteredIds, + compact, +}: { + derived: Derived + scopeId: number + hideInactive: boolean + onPushScope: (opId: number) => void + filterActive: boolean + filteredIds: Set + compact: boolean +}) { + const graph = useMemo( + () => buildScopeGraph(derived, scopeId, hideInactive, filterActive, filteredIds, compact), + [derived, scopeId, hideInactive, filterActive, filteredIds, compact], + ) + const [layout, setLayout] = useState(null) + const [error, setError] = useState(null) + const seqRef = useRef(0) + + useEffect(() => { + if (!graph) { + setLayout(null) + return + } + const seq = ++seqRef.current + setError(null) + computeLayout(graph) + .then((l) => { + if (seq === seqRef.current) setLayout(l) + }) + .catch((err) => { + if (seq === seqRef.current) { + setError(String(err)) + setLayout(null) + } + }) + }, [graph]) + + if (!graph) return
No operators to display
+ if (error) return
Layout error: {error}
+ if (!layout) return
Laying out…
+ + return ( + + ) +} + +function SvgGraph({ + layout, + onPushScope, + filterActive, + filteredIds, +}: { + layout: Layout + onPushScope: (opId: number) => void + filterActive: boolean + filteredIds: Set +}) { + const fbColors = useMemo(() => { + const s = new Set() + for (const r of layout.edgeRoutes) { + if (r.feedbackColor) s.add(r.feedbackColor) + } + return [...s] + }, [layout]) + + return ( + + + + + + {fbColors.map((c) => ( + + ))} + + + + {layout.edgeRoutes.map((r, i) => ( + + ))} + + + {layout.nodes.map((n) => { + const dim = + filterActive && + n.opId != null && + !filteredIds.has(n.opId) + return ( + + ) + })} + + + + ) +} + +function ArrowMarker({ id, color }: { id: string; color: string }) { + return ( + + + + ) +} + +function EdgePath({ route }: { route: EdgeRoute }) { + if (route.points.length < 2) return null + const pts = route.points + let d = `M${pts[0].x},${pts[0].y}` + if (pts.length === 2) { + const cy = Math.abs(pts[1].y - pts[0].y) * 0.3 + d += ` C${pts[0].x},${pts[0].y + cy} ${pts[1].x},${pts[1].y - cy} ${pts[1].x},${pts[1].y}` + } else { + for (let k = 1; k < pts.length; k++) d += ` L${pts[k].x},${pts[k].y}` + } + const sent = route.sent || 0 + const fb = route.feedbackColor + const color = fb ? fb : sent === 0 ? '#555' : '#888' + const marker = fb + ? `url(#arrow-fb-${fb.replace('#', '')})` + : sent === 0 + ? 'url(#arrow-dim)' + : 'url(#arrow)' + return ( + + {route.label && {route.label}} + + ) +} + +function NodeShape({ + node, + onPushScope, + dim, +}: { + node: LaidNode + onPushScope: (opId: number) => void + dim: boolean +}) { + const t = `translate(${node.x},${node.y})` + // If the node is filter-shrunk (thin band, no label), the thinness already + // carries the "deemphasized" signal; full opacity keeps it visible at a + // glance. Otherwise dim drops it to ~20% as before. + const shrunk = !node.isPort && !node.isScope && node.h < 20 + const opacity = dim ? (shrunk ? 1 : 0.2) : 1 + if (node.isScope) { + return ( + + ) + } + return +} + +function ScopeShape({ + node, + transform, + onPushScope, + opacity, +}: { + node: LaidNode + transform: string + onPushScope: (opId: number) => void + opacity: number +}) { + const [hover, setHover] = useState(false) + const w = node.w + const h = node.h + const peak = 8 + const points = `0,${peak} ${w / 2},0 ${w},${peak} ${w},${h} 0,${h}` + const hasSubtitle = node.subtitle != null + const nameY = hasSubtitle ? h / 2 - 4 : h / 2 + 2 + return ( + { + e.stopPropagation() + if (node.opId != null) onPushScope(node.opId) + }} + onMouseEnter={() => setHover(true)} + onMouseLeave={() => setHover(false)} + > + + + {node.label} + + {hasSubtitle && ( + + {node.subtitle} + + )} + + ▶ + + + ) +} + +function FlatShape({ + node, + transform, + opacity, +}: { + node: LaidNode + transform: string + opacity: number +}) { + const hasSubtitle = node.subtitle != null && !node.isPort + const nameY = hasSubtitle ? node.h / 2 - 6 : node.h / 2 + // Filter-shrunk nodes get only the rect — no label fits in a thin band. + const shrunk = !node.isPort && !node.isScope && node.h < 20 + return ( + + + {!shrunk && ( + + {node.label} + + )} + {hasSubtitle && ( + + {node.subtitle} + + )} + + ) +} diff --git a/diagnostics/console/src/components/Overview.tsx b/diagnostics/console/src/components/Overview.tsx new file mode 100644 index 000000000..3852d6d43 --- /dev/null +++ b/diagnostics/console/src/components/Overview.tsx @@ -0,0 +1,97 @@ +import { useMemo, useState } from 'react' +import type { Derived } from '../derive' +import { formatNs } from '../derive' + +type SortCol = 'id' | 'name' | 'operators' | 'messages' | 'elapsed_ns' +type Sort = { col: SortCol; asc: boolean } + +const COLUMNS: { key: SortCol; label: string; num?: boolean }[] = [ + { key: 'id', label: 'Addr' }, + { key: 'name', label: 'Name' }, + { key: 'operators', label: 'Operators', num: true }, + { key: 'messages', label: 'Records', num: true }, + { key: 'elapsed_ns', label: 'Elapsed', num: true }, +] + +export function Overview({ + derived, + filterActive, + filteredIds, + onSelect, +}: { + derived: Derived + filterActive: boolean + filteredIds: Set + onSelect: (id: number) => void +}) { + const [sort, setSort] = useState({ col: 'elapsed_ns', asc: false }) + + const rows = useMemo(() => { + const ids = filterActive + ? derived.rootIds.filter((id) => filteredIds.has(id)) + : derived.rootIds + const out = ids.map((id) => { + const op = derived.operators.get(id)! + return { + id, + name: op.name, + operators: derived.descendantCount.get(id) ?? 0, + messages: derived.transitiveMessages.get(id) ?? 0, + elapsed_ns: derived.sumElapsed.get(id) ?? 0, + } + }) + out.sort((a, b) => { + const va = a[sort.col] + const vb = b[sort.col] + if (typeof va === 'string' && typeof vb === 'string') { + return sort.asc ? va.localeCompare(vb) : vb.localeCompare(va) + } + const na = va as number + const nb = vb as number + return sort.asc ? na - nb : nb - na + }) + return out + }, [derived, sort, filterActive, filteredIds]) + + function clickHeader(col: SortCol) { + setSort((s) => (s.col === col ? { col, asc: !s.asc } : { col, asc: false })) + } + + if (rows.length === 0) { + return ( +
+ {filterActive + ? 'No root dataflows match filter (the filter matches operator names — most matches are typically nested operators, visible in the Detail tab as un-dimmed nodes)' + : 'No dataflows found'} +
+ ) + } + + return ( + + + + {COLUMNS.map((c) => ( + + ))} + + + + {rows.map((r) => ( + onSelect(r.id)}> + + + + + + + ))} + +
clickHeader(c.key)}> + {c.label} + {sort.col === c.key && ( + {sort.asc ? '▲' : '▼'} + )} +
{r.id}{r.name}{r.operators}{r.messages}{formatNs(r.elapsed_ns)}
+ ) +} diff --git a/diagnostics/console/src/derive.ts b/diagnostics/console/src/derive.ts new file mode 100644 index 000000000..21b4a6f0b --- /dev/null +++ b/diagnostics/console/src/derive.ts @@ -0,0 +1,193 @@ +import type { Channel, Operator, StatCounter } from './collections' + +// Derived view of a single operator, combining the raw `Operator` row with +// accumulated stats and structural facts (is_scope, is_arrangement). +export type OperatorView = Operator & { + is_scope: boolean + is_arrangement: boolean + elapsed_ns: number + arr_batches: number + arr_records: number + arr_sharing: number + output_records: number +} + +export type ChannelView = Channel & { + records_sent: number +} + +export type Derived = { + operators: Map + channels: ChannelView[] + addrToId: Map + childrenOf: Map + rootIds: number[] + // Sums over the operator and its descendants. + transitiveMessages: Map + descendantCount: Map + sumElapsed: Map +} + +export function addrKey(addr: readonly number[]): string { + return addr.join(',') +} + +export function addrParent(addr: readonly number[]): number[] { + return addr.slice(0, -1) +} + +// Derive everything view-side from the three base collections in one pass. +// This replaces `rebuildState()` + `computeTransitiveMessages()` from +// `index.html`. Recursive aggregates can't be expressed as a single +// TanStack DB query, so we recompute them imperatively over the live +// query outputs and rely on React memoization for change detection. +export function derive( + operatorRows: Operator[], + channelRows: Channel[], + statRows: StatCounter[], +): Derived { + // Bucket stats by (kind, id). Lookup is by id for operator-owned kinds + // and by id for Messages (which is keyed on channel id). + const elapsed = new Map() + const arrBatches = new Map() + const arrRecords = new Map() + const arrSharing = new Map() + const messages = new Map() + for (const s of statRows) { + switch (s.kind) { + case 'Elapsed': + elapsed.set(s.id, s.value) + break + case 'ArrangementBatches': + arrBatches.set(s.id, s.value) + break + case 'ArrangementRecords': + arrRecords.set(s.id, s.value) + break + case 'Sharing': + arrSharing.set(s.id, s.value) + break + case 'Messages': + messages.set(s.id, s.value) + break + default: + break + } + } + + const addrToId = new Map() + for (const op of operatorRows) addrToId.set(addrKey(op.addr), op.id) + + // childrenOf is keyed by parent addrKey. + const childrenOf = new Map() + for (const op of operatorRows) { + if (op.addr.length > 1) { + const pk = addrKey(addrParent(op.addr)) + const list = childrenOf.get(pk) + if (list) list.push(op.id) + else childrenOf.set(pk, [op.id]) + } + } + + const parentAddrs = new Set(childrenOf.keys()) + + // First pass: build OperatorView entries (output_records filled below). + const ops = new Map() + for (const op of operatorRows) { + const batches = arrBatches.get(op.id) ?? 0 + const records = arrRecords.get(op.id) ?? 0 + const sharing = arrSharing.get(op.id) ?? 0 + ops.set(op.id, { + ...op, + is_scope: parentAddrs.has(addrKey(op.addr)), + is_arrangement: batches > 0 || records > 0 || sharing > 0, + elapsed_ns: elapsed.get(op.id) ?? 0, + arr_batches: batches, + arr_records: records, + arr_sharing: sharing, + output_records: 0, + }) + } + + // Channels with accumulated record counts. + const chans: ChannelView[] = channelRows.map((ch) => ({ + ...ch, + records_sent: messages.get(ch.id) ?? 0, + })) + + // Per-operator output_records by walking channels (skip scope ingress, source[0] === 0). + for (const ch of chans) { + if (ch.source[0] === 0) continue + const srcAddr = [...ch.scope_addr, ch.source[0]] + const srcId = addrToId.get(addrKey(srcAddr)) + if (srcId != null) { + const o = ops.get(srcId) + if (o) o.output_records += ch.records_sent + } + } + + const rootIds: number[] = [] + for (const op of ops.values()) { + if (op.addr.length === 1) rootIds.push(op.id) + } + + // Channels grouped by their containing scope addr — used for transitive sums. + const channelsByScope = new Map() + for (const ch of chans) { + const k = addrKey(ch.scope_addr) + channelsByScope.set(k, (channelsByScope.get(k) ?? 0) + ch.records_sent) + } + + const transitiveMessages = new Map() + const descendantCount = new Map() + const sumElapsed = new Map() + + function visit(id: number) { + if (transitiveMessages.has(id)) return + const op = ops.get(id) + if (!op) { + transitiveMessages.set(id, 0) + descendantCount.set(id, 0) + sumElapsed.set(id, 0) + return + } + let msgs = channelsByScope.get(addrKey(op.addr)) ?? 0 + let count = 1 + let elapsedTotal = op.elapsed_ns + const kids = childrenOf.get(addrKey(op.addr)) ?? [] + for (const kid of kids) { + visit(kid) + msgs += transitiveMessages.get(kid) ?? 0 + count += descendantCount.get(kid) ?? 0 + elapsedTotal += sumElapsed.get(kid) ?? 0 + } + transitiveMessages.set(id, msgs) + descendantCount.set(id, count) + sumElapsed.set(id, elapsedTotal) + } + for (const id of ops.keys()) visit(id) + + return { + operators: ops, + channels: chans, + addrToId, + childrenOf, + rootIds, + transitiveMessages, + descendantCount, + sumElapsed, + } +} + +export function formatNs(ns: number): string { + if (!ns || ns <= 0) return '' + const us = ns / 1e3 + if (us < 1000) return `${us.toFixed(0)}us` + const ms = ns / 1e6 + if (ms < 1000) return `${ms.toFixed(1)}ms` + const s = ns / 1e9 + if (s < 60) return `${s.toFixed(2)}s` + const m = Math.floor(s / 60) + const rs = (s % 60).toFixed(1) + return `${m}m${rs}s` +} diff --git a/diagnostics/console/src/graph/buildScopeGraph.ts b/diagnostics/console/src/graph/buildScopeGraph.ts new file mode 100644 index 000000000..579b5cd7c --- /dev/null +++ b/diagnostics/console/src/graph/buildScopeGraph.ts @@ -0,0 +1,315 @@ +import type { Derived } from '../derive' +import { addrKey, formatNs } from '../derive' + +// A node in the visual scope graph (operator, scope, port, or feedback split). +// Layout fills in (x, y) once ELK runs. Width/height are determined here. +export type GraphNode = { + id: string + opId?: number + label: string + subtitle: string | null + isScope: boolean + isPort: boolean + portDir?: 'in' | 'out' + isFeedbackSrc?: boolean + isFeedbackSink?: boolean + feedbackColor?: string + fill: string + textFill: string + stroke: string + w: number + h: number +} + +export type GraphEdge = { + from: string + to: string + label: string + sent: number + fromPort: string + toPort: string + feedbackColor?: string +} + +export type ScopeGraph = { + nodes: GraphNode[] + edges: GraphEdge[] + nodeMap: Record +} + +// Layout dimension constants. Kept identical to the original index.html. +export const L = { + nodeW: 220, + nodeH: 36, + portW: 80, + portH: 26, + rx: 5, +} + +const FEEDBACK_PALETTE = [ + '#e06c75', + '#61afef', + '#e5c07b', + '#c678dd', + '#56b6c2', + '#d19a66', + '#98c379', + '#be5046', +] + +// Height of a filter-shrunk node: a thin band so ELK reclaims the +// vertical real estate while keeping the graph topology intact. +const SHRUNK_H = 12 + +// Pure function: derive a renderable graph for a given scope from the +// already-derived state. Replaces `buildScopeGraph` in index.html. +export function buildScopeGraph( + derived: Derived, + scopeId: number, + hideInactive: boolean, + filterActive: boolean, + filteredIds: Set, + compact: boolean, +): ScopeGraph | null { + const scope = derived.operators.get(scopeId) + if (!scope) return null + const scopeAddrK = addrKey(scope.addr) + const kids = derived.childrenOf.get(scopeAddrK) ?? [] + if (kids.length === 0) return null + + const nodes: GraphNode[] = [] + const nodeMap: Record = {} + + for (const kid of kids) { + const op = derived.operators.get(kid) + if (!op) continue + + let fill: string + let textFill: string + if (op.is_scope) { + fill = '#12b886' + textFill = '#fff' + } else if (op.is_arrangement) { + fill = '#4a3a6a' + textFill = '#ddf' + } else { + fill = '#2a2a3a' + textFill = '#ccc' + } + + let label = op.name + if (label.length > 28) label = label.slice(0, 25) + '…' + + const subParts: string[] = [] + if (op.is_arrangement) { + subParts.push(op.arr_records.toLocaleString() + ' updates') + } + if (op.elapsed_ns > 0) subParts.push(formatNs(op.elapsed_ns)) + const subtitle = subParts.length > 0 ? subParts.join(' · ') : null + + const id = (op.is_scope ? 'scope_' : 'op_') + kid + // Filter-shrink: non-scope nodes that don't match the active filter + // collapse to a thin band, dropping subtitle so ELK lays them out as + // a flat row. Scopes always keep full size — they're navigational. + // Gated by `compact`: off by default so dimming preserves spatial + // stability across filter changes; toggle on when actively searching. + const shrunk = + filterActive && compact && !op.is_scope && !filteredIds.has(kid) + const effectiveSubtitle = shrunk ? null : subtitle + const effectiveH = shrunk + ? SHRUNK_H + : effectiveSubtitle + ? L.nodeH + 16 + : L.nodeH + const node: GraphNode = { + id, + opId: kid, + label, + subtitle: effectiveSubtitle, + isScope: op.is_scope, + isPort: false, + fill, + textFill, + stroke: op.is_scope + ? '#fff3' + : op.is_arrangement + ? '#7950f2' + : '#5558', + w: L.nodeW, + h: effectiveH, + } + nodes.push(node) + nodeMap[id] = node + } + + // Channels at this scope. Resolves source/target endpoints to either + // an existing child node or a synthetic boundary port. + const scopeChannels = derived.channels.filter( + (ch) => addrKey(ch.scope_addr) === scopeAddrK, + ) + const edges: GraphEdge[] = [] + + for (const ch of scopeChannels) { + let fromId: string + let toId: string + + if (ch.source[0] === 0) { + const pid = 'port_in_' + ch.source[1] + if (!nodeMap[pid]) { + const n: GraphNode = { + id: pid, + label: 'in ' + ch.source[1], + subtitle: null, + isScope: false, + isPort: true, + portDir: 'in', + fill: '#4a4a6a', + textFill: '#ccc', + stroke: '#6668', + w: L.portW, + h: L.portH, + } + nodes.push(n) + nodeMap[pid] = n + } + fromId = pid + } else { + const childAddr = [...scope.addr, ch.source[0]] + const childOpId = derived.addrToId.get(addrKey(childAddr)) + if (childOpId == null) continue + const childOp = derived.operators.get(childOpId) + if (!childOp) continue + fromId = (childOp.is_scope ? 'scope_' : 'op_') + childOpId + } + + if (ch.target[0] === 0) { + const pid = 'port_out_' + ch.target[1] + if (!nodeMap[pid]) { + const n: GraphNode = { + id: pid, + label: 'out ' + ch.target[1], + subtitle: null, + isScope: false, + isPort: true, + portDir: 'out', + fill: '#4a4a6a', + textFill: '#ccc', + stroke: '#6668', + w: L.portW, + h: L.portH, + } + nodes.push(n) + nodeMap[pid] = n + } + toId = pid + } else { + const childAddr = [...scope.addr, ch.target[0]] + const childOpId = derived.addrToId.get(addrKey(childAddr)) + if (childOpId == null) continue + const childOp = derived.operators.get(childOpId) + if (!childOp) continue + toId = (childOp.is_scope ? 'scope_' : 'op_') + childOpId + } + + if (!nodeMap[fromId] || !nodeMap[toId]) continue + + let label = '' + if (ch.records_sent > 0) { + label = ch.records_sent.toLocaleString() + ' records' + } + + edges.push({ + from: fromId, + to: toId, + label, + sent: ch.records_sent, + fromPort: String(ch.source[1]), + toPort: String(ch.target[1]), + }) + } + + // Split Feedback/Variable nodes into source/sink pair so layered layout + // doesn't treat them as a single back-edge target. + const feedbackIds = nodes + .filter( + (n) => + !n.isPort && + !n.isScope && + n.label && + (n.label.toLowerCase().includes('feedback') || + n.label.toLowerCase().includes('variable')), + ) + .map((n) => n.id) + + for (let fi = 0; fi < feedbackIds.length; fi++) { + const fbId = feedbackIds[fi] + const orig = nodeMap[fbId] + const srcId = fbId + '__src' + const sinkId = fbId + '__sink' + const fbColor = FEEDBACK_PALETTE[fi % FEEDBACK_PALETTE.length] + const op = orig.opId != null ? derived.operators.get(orig.opId) : undefined + const localIdx = + op && op.addr.length > 0 ? op.addr[op.addr.length - 1] : '?' + const tag = orig.label + ' [' + localIdx + ']' + + const srcNode: GraphNode = { + ...orig, + id: srcId, + label: tag + ' ▼', + isFeedbackSrc: true, + fill: fbColor, + textFill: '#000', + stroke: fbColor, + feedbackColor: fbColor, + } + const sinkNode: GraphNode = { + ...orig, + id: sinkId, + label: tag + ' ▲', + isFeedbackSink: true, + fill: fbColor, + textFill: '#000', + stroke: fbColor, + feedbackColor: fbColor, + } + nodes.push(srcNode, sinkNode) + nodeMap[srcId] = srcNode + nodeMap[sinkId] = sinkNode + + for (const e of edges) { + if (e.from === fbId) e.from = srcId + if (e.to === fbId) { + e.to = sinkId + e.feedbackColor = fbColor + } + } + const idx = nodes.indexOf(orig) + if (idx >= 0) nodes.splice(idx, 1) + delete nodeMap[fbId] + } + + if (hideInactive) { + const activeEdges = edges.filter((e) => e.sent > 0) + const connected = new Set() + activeEdges.forEach((e) => { + connected.add(e.from) + connected.add(e.to) + }) + const activeNodes = nodes.filter( + (n) => + connected.has(n.id) || + n.isScope || + (n.opId != null && (derived.transitiveMessages.get(n.opId) ?? 0) > 0), + ) + const activeNodeMap: Record = {} + activeNodes.forEach((n) => { + activeNodeMap[n.id] = n + }) + const finalEdges = activeEdges.filter( + (e) => activeNodeMap[e.from] && activeNodeMap[e.to], + ) + return { nodes: activeNodes, edges: finalEdges, nodeMap: activeNodeMap } + } + + return { nodes, edges, nodeMap } +} diff --git a/diagnostics/console/src/graph/layout.ts b/diagnostics/console/src/graph/layout.ts new file mode 100644 index 000000000..e7736bffe --- /dev/null +++ b/diagnostics/console/src/graph/layout.ts @@ -0,0 +1,281 @@ +import ELK from 'elkjs/lib/elk.bundled.js' +import type { GraphNode, ScopeGraph } from './buildScopeGraph' + +const elk = new ELK() +const STACK_GAP = 1 + +export type Point = { x: number; y: number } + +export type LaidNode = GraphNode & { x: number; y: number } + +export type EdgeRoute = { + points: Point[] + isBack: boolean + label: string + sent: number + feedbackColor?: string +} + +export type Layout = { + nodes: LaidNode[] + edgeRoutes: EdgeRoute[] + width: number + height: number +} + +// Detect 1-in-1-out chains so we can stack them vertically into a single +// ELK supernode — keeps the layered output compact instead of sprawling. +function detectChains(graph: ScopeGraph): { + chains: string[][] + nodeChain: Record +} { + const { nodes, edges, nodeMap } = graph + const outCount: Record = {} + const inCount: Record = {} + const outTarget: Record = {} + nodes.forEach((n) => { + outCount[n.id] = 0 + inCount[n.id] = 0 + }) + edges.forEach((e) => { + outCount[e.from] = (outCount[e.from] ?? 0) + 1 + inCount[e.to] = (inCount[e.to] ?? 0) + 1 + outTarget[e.from] = outCount[e.from] === 1 ? e.to : null + }) + + const visited = new Set() + const chains: string[][] = [] + const nodeChain: Record = {} + + for (const n of nodes) { + if (visited.has(n.id)) continue + if (n.isScope || n.isPort) { + visited.add(n.id) + continue + } + const chain = [n.id] + visited.add(n.id) + let cur = n.id + while (true) { + const next = outTarget[cur] + if (!next || visited.has(next)) break + const nn = nodeMap[next] + if (!nn || nn.isScope || nn.isPort) break + if (outCount[cur] !== 1 || inCount[next] !== 1) break + chain.push(next) + visited.add(next) + cur = next + } + if (chain.length > 1) { + const ci = chains.length + chains.push(chain) + chain.forEach((id) => { + nodeChain[id] = ci + }) + } + } + return { chains, nodeChain } +} + +// Run ELK on a scope graph, returning laid-out nodes and edge polylines. +// Replaces `computeLayout` from index.html, minus inline scope expansion. +export async function computeLayout(graph: ScopeGraph): Promise { + const { nodes, edges, nodeMap } = graph + const { chains, nodeChain } = detectChains(graph) + + const nodeOutPorts: Record> = {} + const nodeInPorts: Record> = {} + edges.forEach((e) => { + if (!nodeOutPorts[e.from]) nodeOutPorts[e.from] = new Set() + if (!nodeInPorts[e.to]) nodeInPorts[e.to] = new Set() + nodeOutPorts[e.from].add(e.fromPort || '0') + nodeInPorts[e.to].add(e.toPort || '0') + }) + + function makeElkPorts(nodeId: string) { + const ports: any[] = [] + for (const p of [...(nodeInPorts[nodeId] ?? new Set())].sort()) { + ports.push({ + id: nodeId + '_in_' + p, + layoutOptions: { 'elk.port.side': 'NORTH', 'elk.port.index': p }, + }) + } + for (const p of [...(nodeOutPorts[nodeId] ?? new Set())].sort()) { + ports.push({ + id: nodeId + '_out_' + p, + layoutOptions: { 'elk.port.side': 'SOUTH', 'elk.port.index': p }, + }) + } + return ports + } + + const inChain = new Set() + chains.forEach((ch) => ch.forEach((id) => inChain.add(id))) + + const elkNodes: any[] = [] + + chains.forEach((chain, ci) => { + const stackW = Math.max(...chain.map((id) => nodeMap[id].w)) + const stackH = + chain.reduce((s, id) => s + nodeMap[id].h, 0) + + (chain.length - 1) * STACK_GAP + const chainId = '__chain_' + ci + const firstId = chain[0] + const lastId = chain[chain.length - 1] + const ports: any[] = [] + for (const p of [...(nodeInPorts[firstId] ?? new Set())].sort()) { + ports.push({ + id: chainId + '_in_' + p, + layoutOptions: { 'elk.port.side': 'NORTH', 'elk.port.index': p }, + }) + } + for (const p of [...(nodeOutPorts[lastId] ?? new Set())].sort()) { + ports.push({ + id: chainId + '_out_' + p, + layoutOptions: { 'elk.port.side': 'SOUTH', 'elk.port.index': p }, + }) + } + elkNodes.push({ + id: chainId, + width: stackW, + height: stackH, + ports, + layoutOptions: { + 'elk.algorithm': 'fixed', + 'elk.padding': '[top=0,left=0,bottom=0,right=0]', + 'elk.portConstraints': 'FIXED_SIDE', + }, + children: chain.map((id, k) => { + const n = nodeMap[id] + const prevH = chain + .slice(0, k) + .reduce((s, pid) => s + nodeMap[pid].h + STACK_GAP, 0) + return { + id, + width: n.w, + height: n.h, + x: (stackW - n.w) / 2, + y: prevH, + } + }), + }) + }) + + for (const n of nodes) { + if (inChain.has(n.id)) continue + const elkNode: any = { + id: n.id, + width: n.w, + height: n.h, + ports: makeElkPorts(n.id), + layoutOptions: { 'elk.portConstraints': 'FIXED_SIDE' } as Record< + string, + string + >, + } + if (n.isPort) { + elkNode.layoutOptions[ + 'org.eclipse.elk.layered.layering.layerConstraint' + ] = n.portDir === 'in' ? 'FIRST' : 'LAST' + } + elkNodes.push(elkNode) + } + + function elkNodeId(id: string): string { + return nodeChain[id] !== undefined ? '__chain_' + nodeChain[id] : id + } + function elkSourcePort(e: { from: string; fromPort: string }) { + return elkNodeId(e.from) + '_out_' + (e.fromPort || '0') + } + function elkTargetPort(e: { to: string; toPort: string }) { + return elkNodeId(e.to) + '_in_' + (e.toPort || '0') + } + + const elkEdges: any[] = [] + const elkEdgeOrigIdx: number[] = [] + edges.forEach((e, i) => { + const fromElk = elkNodeId(e.from) + const toElk = elkNodeId(e.to) + if (fromElk === toElk) return + elkEdges.push({ + id: 'e_' + i, + sources: [elkSourcePort(e)], + targets: [elkTargetPort(e)], + layoutOptions: {}, + }) + elkEdgeOrigIdx.push(i) + }) + + const elkGraph = { + id: 'root', + layoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': 'DOWN', + 'elk.layered.spacing.nodeNodeBetweenLayers': '40', + 'elk.spacing.nodeNode': '20', + 'elk.spacing.edgeNode': '15', + 'elk.spacing.edgeEdge': '10', + 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP', + 'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF', + 'elk.padding': '[top=20,left=20,bottom=20,right=20]', + 'elk.layered.feedbackEdges': 'true', + }, + children: elkNodes, + edges: elkEdges, + } + + const laid: any = await elk.layout(elkGraph as any) + const elkResultMap: Record = {} + ;(laid.children ?? []).forEach((en: any) => { + elkResultMap[en.id] = en + }) + + const laidNodes: LaidNode[] = nodes.map((n) => { + if (inChain.has(n.id)) { + const compound = elkResultMap['__chain_' + nodeChain[n.id]] + if (compound && compound.children) { + const child = compound.children.find((c: any) => c.id === n.id) + if (child) { + return { ...n, x: compound.x + child.x, y: compound.y + child.y } + } + } + } else { + const en = elkResultMap[n.id] + if (en) return { ...n, x: en.x, y: en.y } + } + return { ...n, x: 0, y: 0 } + }) + + const edgeRoutes: EdgeRoute[] = [] + ;(laid.edges ?? []).forEach((ee: any, ei: number) => { + const origIdx = elkEdgeOrigIdx[ei] + const origEdge = origIdx !== undefined ? edges[origIdx] : null + const route: EdgeRoute = { + points: [], + isBack: false, + label: origEdge ? origEdge.label : '', + sent: origEdge ? origEdge.sent : 0, + feedbackColor: origEdge?.feedbackColor, + } + if (ee.sections) { + for (const sec of ee.sections) { + route.points.push(sec.startPoint) + if (sec.bendPoints) route.points.push(...sec.bendPoints) + route.points.push(sec.endPoint) + } + } + if (route.points.length >= 2) { + const first = route.points[0] + const last = route.points[route.points.length - 1] + if (last.y < first.y) route.isBack = true + } + edgeRoutes.push(route) + }) + + return { + nodes: laidNodes, + edgeRoutes, + width: laid.width || 400, + height: laid.height || 300, + } +} diff --git a/diagnostics/console/src/main.tsx b/diagnostics/console/src/main.tsx new file mode 100644 index 000000000..1034945f4 --- /dev/null +++ b/diagnostics/console/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './styles.css' +import { App } from './App' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/diagnostics/console/src/styles.css b/diagnostics/console/src/styles.css new file mode 100644 index 000000000..7d6ad5095 --- /dev/null +++ b/diagnostics/console/src/styles.css @@ -0,0 +1,119 @@ +* { box-sizing: border-box; margin: 0; padding: 0; } +body { + font-family: monospace; + background: #1a1a1a; + color: #ccc; + padding: 1em; + display: flex; + flex-direction: column; + gap: 1em; +} +#root { + display: flex; + flex-direction: column; + gap: 1em; +} + +#header { display: flex; align-items: baseline; gap: 1em; flex-wrap: wrap; } +h1 { font-size: 1.2em; color: #fff; } +h2 { font-size: 1em; color: #fff; margin-bottom: 0.5em; } +#status { font-size: 0.85em; color: #888; } +#status.loaded { color: #4c4; } +#status.error { color: #c44; } + +.conn-row { display: flex; gap: 0.5em; flex-wrap: wrap; align-items: center; } +input.ws-url { + font-family: monospace; font-size: 0.85em; + background: #222; color: #ccc; border: 1px solid #444; + padding: 0.3em 0.5em; border-radius: 3px; width: 22em; +} +input.filter-input { + font-family: monospace; font-size: 0.85em; + background: #1a1a1a; color: #ccc; border: 1px solid #444; + padding: 0.2em 0.5em; border-radius: 3px; width: 18em; +} + +button { + font-family: monospace; font-size: 0.85em; + background: #333; color: #ccc; border: 1px solid #555; + padding: 0.3em 0.8em; border-radius: 3px; cursor: pointer; +} +button:hover { background: #444; } +button:disabled { opacity: 0.5; cursor: default; } + +.panel { + background: #222; border: 1px solid #333; + border-radius: 5px; padding: 1em; +} + +.tab-bar { display: flex; gap: 0; margin-bottom: -1px; } +.tab-bar button { + padding: 0.4em 1.2em; border: 1px solid #333; + border-bottom: none; border-radius: 5px 5px 0 0; + background: #1a1a1a; color: #888; font-weight: bold; +} +.tab-bar button.active { + background: #222; color: #fff; border-color: #333; +} +.tab-pane { display: none; } +.tab-pane.active { display: block; } + +table { width: 100%; border-collapse: collapse; font-size: 0.85em; } +th { + text-align: left; color: #888; + border-bottom: 1px solid #444; padding: 0.3em 0.5em; + cursor: pointer; user-select: none; +} +th:hover { color: #fff; } +th .sort-arrow { font-size: 0.7em; margin-left: 0.3em; } +td { padding: 0.3em 0.5em; border-bottom: 1px solid #2a2a2a; } +td.num { text-align: right; font-variant-numeric: tabular-nums; } +tr.clickable { cursor: pointer; } +tr.clickable:hover { background: #2a2a3a; } +tr.selected { background: #2a2a4a; } +.no-data { color: #666; font-style: italic; padding: 1em; text-align: center; } +.dim { color: #666; } + +.controls { + display: flex; gap: 0.5em; align-items: center; + margin-bottom: 0.75em; flex-wrap: wrap; +} + +.breadcrumb { + display: flex; gap: 0.3em; align-items: center; + font-size: 0.85em; margin-bottom: 0.5em; flex-wrap: wrap; +} +.breadcrumb span { color: #888; } +.breadcrumb .current { color: #fff; font-weight: bold; } + +.detail-header { + display: flex; justify-content: space-between; + align-items: baseline; flex-wrap: wrap; gap: 0.5em; + margin-bottom: 0.5em; +} +.detail-header h2 { margin: 0; } +.detail-stats { font-size: 0.85em; color: #888; } + +.legend { + display: flex; gap: 1em; font-size: 0.8em; + color: #888; margin-bottom: 0.5em; flex-wrap: wrap; +} +.legend-item { display: flex; align-items: center; gap: 0.3em; } +.legend-swatch { + width: 14px; height: 14px; border: 1px solid #555; border-radius: 2px; + display: inline-block; +} + +#graph-container { + width: 100%; + overflow: auto; background: #1a1a1a; + border: 1px solid #333; border-radius: 3px; +} +#graph-container svg { cursor: default; } + +#log { + font-size: 0.8em; color: #c44; + max-height: 6em; overflow-y: auto; + white-space: pre-wrap; user-select: text; cursor: text; +} +#log:empty { display: none; } diff --git a/diagnostics/console/src/ws.ts b/diagnostics/console/src/ws.ts new file mode 100644 index 000000000..0656ae386 --- /dev/null +++ b/diagnostics/console/src/ws.ts @@ -0,0 +1,183 @@ +import { createTransaction } from '@tanstack/db' +import { + channels, + operators, + statCounters, + statKey, + clearAll, + type StatKind, +} from './collections' + +// Wire-format updates as emitted by `src/server.rs::JsonUpdate`. +type WireOperator = { + type: 'Operator' + id: number + name: string + addr: number[] + diff: number +} +type WireChannel = { + type: 'Channel' + id: number + scope_addr: number[] + source: [number, number] + target: [number, number] + diff: number +} +type WireStat = { + type: 'Stat' + kind: StatKind + id: number + diff: number +} +type WireUpdate = WireOperator | WireChannel | WireStat + +// Stats that are owned by an operator id; cleaned up when the operator +// is deleted. (Messages are owned by a channel id; cleaned up there.) +const OPERATOR_STAT_KINDS: StatKind[] = [ + 'Elapsed', + 'ArrangementBatches', + 'ArrangementRecords', + 'Sharing', + 'BatcherRecords', + 'BatcherSize', + 'BatcherCapacity', + 'BatcherAllocations', +] + +function applyStat(kind: StatKind, id: number, diff: number) { + const key = statKey(kind, id) + const existing = statCounters.get(key) + if (existing == null) { + if (diff !== 0) { + statCounters.insert({ key, kind, id, value: diff }) + } + } else { + statCounters.update(key, (draft) => { + draft.value += diff + }) + } +} + +function applyOperator(u: WireOperator) { + if (u.diff > 0) { + operators.insert({ id: u.id, name: u.name, addr: u.addr }) + } else if (u.diff < 0) { + if (operators.has(u.id)) operators.delete(u.id) + for (const k of OPERATOR_STAT_KINDS) { + const sk = statKey(k, u.id) + if (statCounters.has(sk)) statCounters.delete(sk) + } + } +} + +function applyChannel(u: WireChannel) { + if (u.diff > 0) { + channels.insert({ + id: u.id, + scope_addr: u.scope_addr, + source: u.source, + target: u.target, + }) + } else if (u.diff < 0) { + if (channels.has(u.id)) channels.delete(u.id) + const sk = statKey('Messages', u.id) + if (statCounters.has(sk)) statCounters.delete(sk) + } +} + +// A Frame is one closed-timestamp commit from the server. The server flushes +// the bucket for `ts_us` only after the capture-stream frontier has advanced +// past that timestamp, so every Frame is a transactionally complete view at +// that logical time. +type WireFrame = { + type: 'Frame' + ts_us: number + updates: WireUpdate[] +} + +// Apply one Frame as a single TanStack DB transaction across all three +// collections. Two consequences fall out of this: +// 1. A `useLiveQuery` observer never sees a half-applied frame. +// 2. Multiple independent live queries are mutually consistent: they all +// observe the same sequence of closed timestamps, so cross-collection +// invariants (e.g. "an operator and its stat counters appear together") +// hold at every observable point. +export function applyFrame(frame: WireFrame) { + if (frame.updates.length === 0) return + const tx = createTransaction({ + mutationFn: async ({ transaction }) => { + operators.utils.acceptMutations(transaction) + channels.utils.acceptMutations(transaction) + statCounters.utils.acceptMutations(transaction) + }, + }) + tx.mutate(() => { + for (const u of frame.updates) { + switch (u.type) { + case 'Operator': + applyOperator(u) + break + case 'Channel': + applyChannel(u) + break + case 'Stat': + applyStat(u.kind, u.id, u.diff) + break + } + } + }) + void tx.commit() +} + +export type ConnState = + | { kind: 'idle' } + | { kind: 'connecting'; url: string } + | { kind: 'open'; url: string; updates: number } + | { kind: 'closed'; updates: number } + | { kind: 'error'; message: string } + +export type WsConn = { + close: () => void +} + +export function connect( + url: string, + onState: (s: ConnState) => void, + onError: (msg: string) => void, +): WsConn { + clearAll() + let updates = 0 + onState({ kind: 'connecting', url }) + + const ws = new WebSocket(url) + + ws.addEventListener('open', () => { + onState({ kind: 'open', url, updates }) + }) + + ws.addEventListener('message', (event) => { + try { + const data = JSON.parse(event.data as string) + if (data && data.type === 'Frame') { + const frame = data as WireFrame + applyFrame(frame) + updates += frame.updates.length + onState({ kind: 'open', url, updates }) + } + } catch { + // ignore parse errors — tolerate malformed frames + } + }) + + ws.addEventListener('close', () => { + onState({ kind: 'closed', updates }) + }) + + ws.addEventListener('error', () => { + onError(`WebSocket error connecting to ${url}`) + onState({ kind: 'error', message: `Connection error: ${url}` }) + }) + + return { close: () => ws.close() } +} diff --git a/diagnostics/console/tsconfig.app.json b/diagnostics/console/tsconfig.app.json new file mode 100644 index 000000000..7f42e5f7c --- /dev/null +++ b/diagnostics/console/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/diagnostics/console/tsconfig.json b/diagnostics/console/tsconfig.json new file mode 100644 index 000000000..1ffef600d --- /dev/null +++ b/diagnostics/console/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/diagnostics/console/tsconfig.node.json b/diagnostics/console/tsconfig.node.json new file mode 100644 index 000000000..d3c52ea64 --- /dev/null +++ b/diagnostics/console/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/diagnostics/console/vite.config.ts b/diagnostics/console/vite.config.ts new file mode 100644 index 000000000..8b0f57b91 --- /dev/null +++ b/diagnostics/console/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +}) diff --git a/diagnostics/index.html b/diagnostics/index.html index 403e39b2d..129965653 100644 --- a/diagnostics/index.html +++ b/diagnostics/index.html @@ -485,8 +485,13 @@

ws.addEventListener('message', event => { try { const data = JSON.parse(event.data); - if (Array.isArray(data)) { - applyBatch(data); + // Wire format is one Frame envelope per WebSocket message: + // { type: "Frame", ts_us: , updates: [...] } + // The server only emits a Frame once the capture frontier has + // advanced past `ts_us`, so each Frame is a complete view at that + // closed logical timestamp. + if (data && data.type === 'Frame' && Array.isArray(data.updates)) { + applyBatch(data.updates); } } catch (e) { // ignore parse errors diff --git a/diagnostics/src/server.rs b/diagnostics/src/server.rs index a0a40c2b6..1fa533d57 100644 --- a/diagnostics/src/server.rs +++ b/diagnostics/src/server.rs @@ -14,7 +14,7 @@ //! (e.g., `python3 -m http.server 8000`). A future improvement could embed //! static file serving here so only one port is needed. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::net::TcpListener; use std::sync::mpsc; use std::thread; @@ -62,6 +62,15 @@ impl Server { pub fn start(port: u16, sink: SinkHandle) -> Self { let handle = thread::spawn(move || run_server(port, sink)); eprintln!("Diagnostics server on ws://localhost:{port}"); + eprintln!(" view with one of:"); + eprintln!( + " diagnostics/index.html — single-file UI \ + (cd diagnostics && python3 -m http.server 8000, then open localhost:8000/index.html)" + ); + eprintln!( + " diagnostics/console/ — React UI \ + (cd diagnostics/console && npm install && npm run dev)" + ); Server { _handle: handle } } } @@ -155,6 +164,23 @@ fn run_server(port: u16, sink: SinkHandle) { let mut clients: HashMap> = HashMap::new(); let mut next_client_id: usize = 0; + // Per-client buffer of records awaiting their timestamp to close, keyed + // by the inner `ts` of each record. A timestamp is "closed" when the + // capture stream's frontier (tracked in `progress_counts`) advances past + // it — at which point we flush the bucket as a single Frame to the client. + let mut pending: HashMap>> = + HashMap::new(); + // Running multiplicities from `Event::Progress` updates. The current + // frontier is the smallest key with positive count; an entry at zero is + // removed. Anything strictly less than the smallest live key is closed. + // + // Timely's capture protocol sends progress as *deltas* relative to an + // assumed initial frontier of `{T::default(): 1}` — so we must seed the + // counter that way, otherwise the first event's `(0ns, -1)` retraction + // leaves us at `{0ns: -1}` and the frontier sticks at 0 forever. + let mut progress_counts: BTreeMap = BTreeMap::new(); + progress_counts.insert(Duration::default(), 1); + loop { // Accept pending connections. loop { @@ -183,17 +209,34 @@ fn run_server(port: u16, sink: SinkHandle) { } } - // Drain diagnostic updates and group by client. - let mut batches_by_client: HashMap> = HashMap::new(); + // Drain diagnostic updates: bucket records by their inner timestamp + // per client; absorb progress updates into the running frontier. + let mut frontier_changed = false; loop { match receiver.try_recv() { - Ok(Event::Messages(_time, data)) => { - for ((client_id, update), _ts, diff) in data { + Ok(Event::Messages(_envelope_time, data)) => { + // The capture envelope time is incidental; the meaningful + // logical time is the per-record `ts`. + for ((client_id, update), ts, diff) in data { let json = update_to_json(&update, diff); - batches_by_client.entry(client_id).or_default().push(json); + pending + .entry(client_id) + .or_default() + .entry(ts) + .or_default() + .push(json); } } - Ok(Event::Progress(_)) => {} + Ok(Event::Progress(updates)) => { + for (t, diff) in updates { + let entry = progress_counts.entry(t).or_insert(0); + *entry += diff; + if *entry == 0 { + progress_counts.remove(&t); + } + } + frontier_changed = true; + } Err(mpsc::TryRecvError::Empty) => break, Err(mpsc::TryRecvError::Disconnected) => { eprintln!("Diagnostics output channel closed, shutting down server"); @@ -206,14 +249,37 @@ fn run_server(port: u16, sink: SinkHandle) { } } - // Send batched updates to each client. + // If the frontier moved, flush every closed timestamp bucket. One + // Frame per closed `ts`, in timestamp order, so each Frame is one + // atomic transaction on the client. let mut disconnected = Vec::new(); - for (client_id, updates) in &batches_by_client { - if let Some(ws) = clients.get_mut(client_id) { - if !updates.is_empty() { - let payload = serde_json::to_string(updates).unwrap(); + if frontier_changed { + // Anything strictly less than the smallest live progress count + // is closed. If `progress_counts` is empty, every buffered + // timestamp is closed. + let frontier: Option = progress_counts.keys().next().copied(); + for (client_id, buckets) in pending.iter_mut() { + let closed: Vec = match frontier { + Some(f) => buckets.range(..f).map(|(t, _)| *t).collect(), + None => buckets.keys().copied().collect(), + }; + for ts in closed { + let updates = buckets.remove(&ts).unwrap_or_default(); + if updates.is_empty() { + continue; + } + let Some(ws) = clients.get_mut(client_id) else { + continue; + }; + let frame = serde_json::json!({ + "type": "Frame", + "ts_us": ts.as_micros() as u64, + "updates": updates, + }); + let payload = serde_json::to_string(&frame).unwrap(); if ws.send(Message::Text(payload.into())).is_err() { disconnected.push(*client_id); + break; } } } @@ -229,6 +295,7 @@ fn run_server(port: u16, sink: SinkHandle) { } for client_id in disconnected { clients.remove(&client_id); + pending.remove(&client_id); client_input.disconnect(client_id, start.elapsed()); eprintln!("Diagnostics client {client_id} disconnected"); } diff --git a/differential-dataflow/Cargo.toml b/differential-dataflow/Cargo.toml index 5a42ce9b2..0e6b74bfb 100644 --- a/differential-dataflow/Cargo.toml +++ b/differential-dataflow/Cargo.toml @@ -25,6 +25,8 @@ itertools="^0.13" graph_map = "0.1" bytemuck = "1.18.0" mimalloc = "0.1.48" +tempfile = "3" +lz4_flex = "0.11" [dependencies] columnar = { workspace = true } diff --git a/differential-dataflow/examples/columnar/columnar_support.rs b/differential-dataflow/examples/columnar/columnar_support.rs deleted file mode 100644 index 1241702e7..000000000 --- a/differential-dataflow/examples/columnar/columnar_support.rs +++ /dev/null @@ -1,2009 +0,0 @@ -//! Columnar container infrastructure for differential dataflow. -//! -//! Provides trie-structured update storage (`Updates`, `RecordedUpdates`), -//! columnar arrangement types (`ValSpine`, `ValBatcher`, `ValBuilder`), -//! container traits for iterative scopes (`Enter`, `Leave`, `Negate`, `ResultsIn`), -//! exchange distribution (`ValPact`), and operators (`join_function`, `leave_dynamic`). -//! -//! Include via `#[path = "columnar_support.rs"] mod columnar_support;` - -#![allow(dead_code, unused_imports)] - -pub use layout::{ColumnarLayout, ColumnarUpdate}; -pub mod layout { - - use std::fmt::Debug; - use columnar::Columnar; - use differential_dataflow::trace::implementations::{Layout, OffsetList}; - use differential_dataflow::difference::Semigroup; - use differential_dataflow::lattice::Lattice; - use timely::progress::Timestamp; - - /// A layout based on columnar - pub struct ColumnarLayout { - phantom: std::marker::PhantomData, - } - - impl ColumnarUpdate for (K, V, T, R) - where - K: Columnar + Debug + Ord + Clone + 'static, - V: Columnar + Debug + Ord + Clone + 'static, - T: Columnar + Debug + Ord + Default + Clone + Lattice + Timestamp, - R: Columnar + Debug + Ord + Default + Semigroup + 'static, - { - type Key = K; - type Val = V; - type Time = T; - type Diff = R; - } - - use crate::arrangement::Coltainer; - impl Layout for ColumnarLayout { - type KeyContainer = Coltainer; - type ValContainer = Coltainer; - type TimeContainer = Coltainer; - type DiffContainer = Coltainer; - type OffsetContainer = OffsetList; - } - - /// A type that names constituent update types. - /// - /// We will use their associated `Columnar::Container` - pub trait ColumnarUpdate : Debug + 'static { - type Key: Columnar + Debug + Ord + Clone + 'static; - type Val: Columnar + Debug + Ord + Clone + 'static; - type Time: Columnar + Debug + Ord + Default + Clone + Lattice + Timestamp; - type Diff: Columnar + Debug + Ord + Default + Semigroup + 'static; - } - - /// A container whose references can be ordered. - pub trait OrdContainer : for<'a> columnar::Container : Ord> { } - impl columnar::Container : Ord>> OrdContainer for C { } - -} - -pub use updates::Updates; - -/// A thin wrapper around `Updates` that tracks the pre-consolidation record count -/// for timely's exchange accounting. This wrapper is the stream container type; -/// the `TrieChunker` strips it, passing bare `Updates` into the merge batcher. -pub struct RecordedUpdates { - pub updates: Updates, - pub records: usize, - /// Whether `updates` is known to be sorted and consolidated - /// (no duplicate (key, val, time) triples, no zero diffs). - pub consolidated: bool, -} - -impl Default for RecordedUpdates { - fn default() -> Self { Self { updates: Default::default(), records: 0, consolidated: true } } -} - -impl Clone for RecordedUpdates { - fn clone(&self) -> Self { Self { updates: self.updates.clone(), records: self.records, consolidated: self.consolidated } } -} - -impl timely::Accountable for RecordedUpdates { - #[inline] fn record_count(&self) -> i64 { self.records as i64 } -} - -impl timely::dataflow::channels::ContainerBytes for RecordedUpdates { - fn from_bytes(_bytes: timely::bytes::arc::Bytes) -> Self { unimplemented!() } - fn length_in_bytes(&self) -> usize { unimplemented!() } - fn into_bytes(&self, _writer: &mut W) { unimplemented!() } -} - -// Container trait impls for RecordedUpdates, enabling iterative scopes. -mod container_impls { - use columnar::{Borrow, Columnar, Index, Len, Push}; - use timely::progress::{Timestamp, timestamp::Refines}; - use differential_dataflow::difference::Abelian; - use differential_dataflow::collection::containers::{Negate, Enter, Leave, ResultsIn}; - - use crate::layout::ColumnarUpdate as Update; - use crate::{RecordedUpdates, Updates}; - - impl> Negate for RecordedUpdates { - fn negate(mut self) -> Self { - let len = self.updates.diffs.values.len(); - let mut new_diffs = <::Container as Default>::default(); - let mut owned = U::Diff::default(); - for i in 0..len { - columnar::Columnar::copy_from(&mut owned, self.updates.diffs.values.borrow().get(i)); - owned.negate(); - new_diffs.push(&owned); - } - self.updates.diffs.values = new_diffs; - self - } - } - - impl Enter for RecordedUpdates<(K, V, T1, R)> - where - (K, V, T1, R): Update, - (K, V, T2, R): Update, - T1: Timestamp + Columnar + Default + Clone, - T2: Refines + Columnar + Default + Clone, - K: Columnar, V: Columnar, R: Columnar, - { - type InnerContainer = RecordedUpdates<(K, V, T2, R)>; - fn enter(self) -> Self::InnerContainer { - // Rebuild the time column; everything else moves as-is. - let mut new_times = <::Container as Default>::default(); - let mut t1_owned = T1::default(); - for i in 0..self.updates.times.values.len() { - Columnar::copy_from(&mut t1_owned, self.updates.times.values.borrow().get(i)); - let t2 = T2::to_inner(t1_owned.clone()); - new_times.push(&t2); - } - // TODO: Assumes Enter (to_inner) is order-preserving on times. - RecordedUpdates { - consolidated: self.consolidated, - updates: Updates { - keys: self.updates.keys, - vals: self.updates.vals, - times: crate::updates::Lists { values: new_times, bounds: self.updates.times.bounds }, - diffs: self.updates.diffs, - }, - records: self.records, - } - } - } - - impl Leave for RecordedUpdates<(K, V, T1, R)> - where - (K, V, T1, R): Update, - (K, V, T2, R): Update, - T1: Refines + Columnar + Default + Clone, - T2: Timestamp + Columnar + Default + Clone, - K: Columnar, V: Columnar, R: Columnar, - { - type OuterContainer = RecordedUpdates<(K, V, T2, R)>; - fn leave(self) -> Self::OuterContainer { - // Flatten, convert times, and reconsolidate via consolidate. - // Leave can collapse distinct T1 times to the same T2 time, - // so the trie must be rebuilt with consolidation. - let mut flat = Updates::<(K, V, T2, R)>::default(); - let mut t1_owned = T1::default(); - for (k, v, t, d) in self.updates.iter() { - Columnar::copy_from(&mut t1_owned, t); - let t2: T2 = t1_owned.clone().to_outer(); - flat.push((k, v, &t2, d)); - } - RecordedUpdates { - updates: flat.consolidate(), - records: self.records, - consolidated: true, - } - } - } - - impl ResultsIn<::Summary> for RecordedUpdates { - fn results_in(self, step: &::Summary) -> Self { - use timely::progress::PathSummary; - // Apply results_in to each time; drop updates whose time maps to None. - // This must rebuild the trie since some entries may be removed. - let mut output = Updates::::default(); - let mut time_owned = U::Time::default(); - for (k, v, t, d) in self.updates.iter() { - Columnar::copy_from(&mut time_owned, t); - if let Some(new_time) = step.results_in(&time_owned) { - output.push((k, v, &new_time, d)); - } - } - // TODO: Time advancement may not be order preserving, but .. it could be. - // TODO: Before this is consolidated the above would need to be `form`ed. - RecordedUpdates { updates: output, records: self.records, consolidated: false } - } - } -} - -pub use column_builder::ValBuilder as ValColBuilder; -mod column_builder { - - use std::collections::VecDeque; - use columnar::{Columnar, Clear, Len, Push}; - - use crate::layout::ColumnarUpdate as Update; - use crate::{Updates, RecordedUpdates}; - - type TupleContainer = <(::Key, ::Val, ::Time, ::Diff) as Columnar>::Container; - - /// A container builder that produces `RecordedUpdates` (sorted, consolidated trie + record count). - pub struct ValBuilder { - /// Container that we're writing to. - current: TupleContainer, - /// Empty allocation. - empty: Option>, - /// Completed containers pending to be sent. - pending: VecDeque>, - } - - use timely::container::PushInto; - impl PushInto for ValBuilder where TupleContainer : Push { - #[inline] - fn push_into(&mut self, item: T) { - self.current.push(item); - if self.current.len() > 1024 * 1024 { - use columnar::{Borrow, Index}; - let records = self.current.len(); - let mut refs = self.current.borrow().into_index_iter().collect::>(); - refs.sort(); - let updates = Updates::form(refs.into_iter()); - self.pending.push_back(RecordedUpdates { updates, records, consolidated: true }); - self.current.clear(); - } - } - } - - impl Default for ValBuilder { - fn default() -> Self { - ValBuilder { - current: Default::default(), - empty: None, - pending: Default::default(), - } - } - } - - use timely::container::{ContainerBuilder, LengthPreservingContainerBuilder}; - impl ContainerBuilder for ValBuilder { - type Container = RecordedUpdates; - - #[inline] - fn extract(&mut self) -> Option<&mut Self::Container> { - if let Some(container) = self.pending.pop_front() { - self.empty = Some(container); - self.empty.as_mut() - } else { - None - } - } - - #[inline] - fn finish(&mut self) -> Option<&mut Self::Container> { - if !self.current.is_empty() { - use columnar::{Borrow, Index}; - let records = self.current.len(); - let mut refs = self.current.borrow().into_index_iter().collect::>(); - refs.sort(); - let updates = Updates::form(refs.into_iter()); - self.pending.push_back(RecordedUpdates { updates, records, consolidated: true }); - self.current.clear(); - } - self.empty = self.pending.pop_front(); - self.empty.as_mut() - } - } - - impl LengthPreservingContainerBuilder for ValBuilder { } - -} - -pub use distributor::ValPact; -mod distributor { - - use std::rc::Rc; - - use columnar::{Borrow, Index, Len}; - use timely::logging::TimelyLogger; - use timely::dataflow::channels::pushers::{Exchange, exchange::Distributor}; - use timely::dataflow::channels::Message; - use timely::dataflow::channels::pact::{LogPuller, LogPusher, ParallelizationContract}; - use timely::progress::Timestamp; - use timely::worker::Worker; - - use crate::layout::ColumnarUpdate as Update; - use crate::{Updates, RecordedUpdates}; - - pub struct ValDistributor { - marker: std::marker::PhantomData, - hashfunc: H, - pre_lens: Vec, - } - - impl FnMut(columnar::Ref<'a, U::Key>)->u64> Distributor> for ValDistributor { - // TODO: For unsorted Updates (stride-1 outer keys), each key is its own outer group, - // so the per-group pre_lens snapshot and seal check costs O(keys × workers). Should - // either batch keys by destination first, or detect stride-1 outer bounds and use a - // simpler single-pass partitioning that seals once at the end. - fn partition>>>(&mut self, container: &mut RecordedUpdates, time: &T, pushers: &mut [P]) { - use crate::updates::child_range; - - let keys_b = container.updates.keys.borrow(); - let mut outputs: Vec> = (0..pushers.len()).map(|_| Updates::default()).collect(); - - // Each outer key group becomes a separate run in the destination. - for outer in 0..Len::len(&keys_b) { - self.pre_lens.clear(); - self.pre_lens.extend(outputs.iter().map(|o| o.keys.values.len())); - for k in child_range(keys_b.bounds, outer) { - let key = keys_b.values.get(k); - let idx = ((self.hashfunc)(key) as usize) % pushers.len(); - outputs[idx].extend_from_keys(&container.updates, k..k+1); - } - for (output, &pre) in outputs.iter_mut().zip(self.pre_lens.iter()) { - if output.keys.values.len() > pre { - output.keys.bounds.push(output.keys.values.len() as u64); - } - } - } - - // Distribute the input's record count across non-empty outputs. - let total_records = container.records; - let non_empty: usize = outputs.iter().filter(|o| !o.keys.values.is_empty()).count(); - let mut first_records = total_records.saturating_sub(non_empty.saturating_sub(1)); - for (pusher, output) in pushers.iter_mut().zip(outputs) { - if !output.keys.values.is_empty() { - let recorded = RecordedUpdates { updates: output, records: first_records, consolidated: container.consolidated }; - first_records = 1; - let mut recorded = recorded; - Message::push_at(&mut recorded, time.clone(), pusher); - } - } - } - fn flush>>>(&mut self, _time: &T, _pushers: &mut [P]) { } - fn relax(&mut self) { } - } - - pub struct ValPact { pub hashfunc: H } - - impl ParallelizationContract> for ValPact - where - T: Timestamp, - U: Update, - H: for<'a> FnMut(columnar::Ref<'a, U::Key>)->u64 + 'static, - { - type Pusher = Exchange< - T, - LogPusher>>>>, - ValDistributor - >; - type Puller = LogPuller>>>>; - - fn connect(self, worker: &Worker, identifier: usize, address: Rc<[usize]>, logging: Option) -> (Self::Pusher, Self::Puller) { - let (senders, receiver) = worker.allocate::>>(identifier, address); - let senders = senders.into_iter().enumerate().map(|(i,x)| LogPusher::new(x, worker.index(), i, identifier, logging.clone())).collect::>(); - let distributor = ValDistributor { - marker: std::marker::PhantomData, - hashfunc: self.hashfunc, - pre_lens: Vec::new(), - }; - (Exchange::new(senders, distributor), LogPuller::new(receiver, worker.index(), identifier, logging.clone())) - } - } -} - -pub use arrangement::{ValBatcher, ValBuilder, ValSpine}; -pub mod arrangement { - - use std::rc::Rc; - use differential_dataflow::trace::implementations::ord_neu::OrdValBatch; - use differential_dataflow::trace::rc_blanket_impls::RcBuilder; - use differential_dataflow::trace::implementations::spine_fueled::Spine; - - use crate::layout::ColumnarLayout; - - /// A trace implementation backed by columnar storage. - pub type ValSpine = Spine>>>; - /// A batcher for columnar storage. - pub type ValBatcher = ValBatcher2<(K,V,T,R)>; - /// A builder for columnar storage. - pub type ValBuilder = RcBuilder>; - - /// A batch container implementation for Coltainer. - pub use batch_container::Coltainer; - pub mod batch_container { - - use columnar::{Borrow, Columnar, Container, Clear, Push, Index, Len}; - use differential_dataflow::trace::implementations::BatchContainer; - - /// Container, anchored by `C` to provide an owned type. - pub struct Coltainer { - pub container: C::Container, - } - - impl Default for Coltainer { - fn default() -> Self { Self { container: Default::default() } } - } - - impl BatchContainer for Coltainer where for<'a> columnar::Ref<'a, C> : Ord { - - type ReadItem<'a> = columnar::Ref<'a, C>; - type Owned = C; - - #[inline(always)] fn into_owned<'a>(item: Self::ReadItem<'a>) -> Self::Owned { C::into_owned(item) } - #[inline(always)] fn clone_onto<'a>(item: Self::ReadItem<'a>, other: &mut Self::Owned) { other.copy_from(item) } - - #[inline(always)] fn push_ref(&mut self, item: Self::ReadItem<'_>) { self.container.push(item) } - #[inline(always)] fn push_own(&mut self, item: &Self::Owned) { self.container.push(item) } - - /// Clears the container. May not release resources. - fn clear(&mut self) { self.container.clear() } - - /// Creates a new container with sufficient capacity. - fn with_capacity(_size: usize) -> Self { Self::default() } - /// Creates a new container with sufficient capacity. - fn merge_capacity(cont1: &Self, cont2: &Self) -> Self { - Self { - container: ::Container::with_capacity_for([cont1.container.borrow(), cont2.container.borrow()].into_iter()), - } - } - - /// Converts a read item into one with a narrower lifetime. - #[inline(always)] fn reborrow<'b, 'a: 'b>(item: Self::ReadItem<'a>) -> Self::ReadItem<'b> { columnar::ContainerOf::::reborrow_ref(item) } - - /// Reference to the element at this position. - #[inline(always)] fn index(&self, index: usize) -> Self::ReadItem<'_> { self.container.borrow().get(index) } - - #[inline(always)] fn len(&self) -> usize { self.container.len() } - } - } - - use crate::{Updates, RecordedUpdates}; - use differential_dataflow::trace::implementations::merge_batcher::MergeBatcher; - type ValBatcher2 = MergeBatcher, TrieChunker, trie_merger::TrieMerger>; - - /// A chunker that unwraps `RecordedUpdates` into bare `Updates` for the merge batcher. - /// The `records` accounting is discarded here — it has served its purpose for exchange. - /// - /// IMPORTANT: This chunker assumes the input `Updates` are sorted and consolidated - /// (as produced by `ValColBuilder::form`). The downstream `InternalMerge` relies on - /// this invariant. If `RecordedUpdates` could carry unsorted data (e.g. from a `map`), - /// we would need either a sorting chunker for that case, or a type-level distinction - /// (e.g. `RecordedUpdates` vs `RecordedUpdates`) to - /// route to the right chunker. - pub struct TrieChunker { - ready: std::collections::VecDeque>, - empty: Option>, - } - - impl Default for TrieChunker { - fn default() -> Self { Self { ready: Default::default(), empty: None } } - } - - impl<'a, U: crate::layout::ColumnarUpdate> timely::container::PushInto<&'a mut RecordedUpdates> for TrieChunker { - fn push_into(&mut self, container: &'a mut RecordedUpdates) { - let mut updates = std::mem::take(&mut container.updates); - if !container.consolidated { updates = updates.consolidate(); } - if updates.len() > 0 { self.ready.push_back(updates); } - } - } - - impl timely::container::ContainerBuilder for TrieChunker { - type Container = Updates; - fn extract(&mut self) -> Option<&mut Self::Container> { - if let Some(ready) = self.ready.pop_front() { - self.empty = Some(ready); - self.empty.as_mut() - } else { - None - } - } - fn finish(&mut self) -> Option<&mut Self::Container> { - self.empty = self.ready.pop_front(); - self.empty.as_mut() - } - } - - pub mod batcher { - - use columnar::{Borrow, Columnar, Index, Len, Push}; - use differential_dataflow::difference::{Semigroup, IsZero}; - use timely::progress::frontier::{Antichain, AntichainRef}; - use differential_dataflow::trace::implementations::merge_batcher::container::InternalMerge; - - use crate::ColumnarUpdate as Update; - use crate::Updates; - - impl timely::container::SizableContainer for Updates { - fn at_capacity(&self) -> bool { self.diffs.values.len() >= 64 * 1024 } - fn ensure_capacity(&mut self, _stash: &mut Option) { } - } - - /// Required by `reduce_abelian`'s bound `Builder::Input: InternalMerge`. - /// Not called at runtime — our batcher uses `TrieMerger` instead. - /// TODO: Relax the bound in DD's reduce to remove this requirement. - impl InternalMerge for Updates { - type TimeOwned = U::Time; - fn len(&self) -> usize { unimplemented!() } - fn clear(&mut self) { - use columnar::Clear; - self.keys.clear(); - self.vals.clear(); - self.times.clear(); - self.diffs.clear(); - } - fn merge_from(&mut self, _others: &mut [Self], _positions: &mut [usize]) { unimplemented!() } - fn extract(&mut self, - _position: &mut usize, - _upper: AntichainRef, - _frontier: &mut Antichain, - _keep: &mut Self, - _ship: &mut Self, - ) { unimplemented!() } - } - } - - pub mod trie_merger { - - use columnar::{Columnar, Len}; - use timely::PartialOrder; - use timely::progress::frontier::{Antichain, AntichainRef}; - use differential_dataflow::trace::implementations::merge_batcher::Merger; - - use crate::ColumnarUpdate as Update; - use crate::Updates; - - pub struct TrieMerger { - _marker: std::marker::PhantomData, - } - - impl Default for TrieMerger { - fn default() -> Self { Self { _marker: std::marker::PhantomData } } - } - - /// A merging iterator over two sorted iterators. - struct Merging { - iter1: std::iter::Peekable, - iter2: std::iter::Peekable, - } - - impl Iterator for Merging - where - K: Copy + Ord, - V: Copy + Ord, - T: Copy + Ord, - I1: Iterator, - I2: Iterator, - { - type Item = (K, V, T, D); - #[inline] - fn next(&mut self) -> Option { - match (self.iter1.peek(), self.iter2.peek()) { - (Some(a), Some(b)) => { - if (a.0, a.1, a.2) <= (b.0, b.1, b.2) { - self.iter1.next() - } else { - self.iter2.next() - } - } - (Some(_), None) => self.iter1.next(), - (None, Some(_)) => self.iter2.next(), - (None, None) => None, - } - } - } - - /// Build sorted `Updates` chunks from a sorted iterator of refs, - /// using `Updates::form` (which consolidates internally) on batches. - fn form_chunks<'a, U: Update>( - sorted: impl Iterator>>, - output: &mut Vec>, - ) { - let mut sorted = sorted.peekable(); - while sorted.peek().is_some() { - let chunk = Updates::::form((&mut sorted).take(64 * 1024)); - if chunk.len() > 0 { - output.push(chunk); - } - } - } - - impl Merger for TrieMerger - where - U::Time: 'static, - { - type Chunk = Updates; - type Time = U::Time; - - fn merge( - &mut self, - list1: Vec>, - list2: Vec>, - output: &mut Vec>, - _stash: &mut Vec>, - ) { - Self::merge_batches(list1, list2, output, _stash); - } - - fn extract( - &mut self, - merged: Vec, - upper: AntichainRef, - frontier: &mut Antichain, - ship: &mut Vec, - kept: &mut Vec, - _stash: &mut Vec, - ) { - // Flatten the sorted, consolidated chain into refs. - let all = merged.iter().flat_map(|chunk| chunk.iter()); - - // Partition into two sorted streams by time. - let mut time_owned = U::Time::default(); - let mut keep_vec = Vec::new(); - let mut ship_vec = Vec::new(); - for (k, v, t, d) in all { - Columnar::copy_from(&mut time_owned, t); - if upper.less_equal(&time_owned) { - frontier.insert_ref(&time_owned); - keep_vec.push((k, v, t, d)); - } else { - ship_vec.push((k, v, t, d)); - } - } - - // Build chunks via form (which consolidates internally). - form_chunks::(keep_vec.into_iter(), kept); - form_chunks::(ship_vec.into_iter(), ship); - } - - fn account(chunk: &Self::Chunk) -> (usize, usize, usize, usize) { - use timely::Accountable; - (chunk.record_count() as usize, 0, 0, 0) - } - } - - impl TrieMerger - where - U::Time: 'static, - { - /// Iterator-based merge: flatten, merge, consolidate, form. - /// Correct but slow — used as fallback. - #[allow(dead_code)] - fn merge_iterator( - list1: &[Updates], - list2: &[Updates], - output: &mut Vec>, - ) { - let iter1 = list1.iter().flat_map(|chunk| chunk.iter()); - let iter2 = list2.iter().flat_map(|chunk| chunk.iter()); - - let merged = Merging { - iter1: iter1.peekable(), - iter2: iter2.peekable(), - }; - - form_chunks::(merged, output); - } - - /// A merge implementation that operates batch-at-a-time. - #[inline(never)] - fn merge_batches( - list1: Vec>, - list2: Vec>, - output: &mut Vec>, - stash: &mut Vec>, - ) { - - // The design for efficient "batch" merginging of chains of links is: - // 0. We choose a target link size, K, and will keep the average link size at least K and the max size at 2k. - // K should be large enough to amortize some set-up, but not so large that one or two extra break the bank. - // 1. We will repeatedly consider pairs of links, and fully merge one with a prefix of the other. - // The last elements of each link will tell us which of the two suffixes must be held back. - // 2. We then have a chain of as many links as we started with, with potential defects to correct: - // a. A link may contain some number of zeros: we can remove them if we are eager, based on size. - // b. A link may contain more than 2K updates; we can split it. - // c. Two adjacent links may contain fewer than 2K updates; we can meld (careful append) them. - // 3. After a pass of the above, we should have restored the invariant. - // We can try and me smarter and fuse some of the above work rather than explicitly stage results. - // - // The challenging moment is the merge that can start with a suffix of one link, involving a prefix of one link. - // These could be the same link, different links, and generally there is the potential for complexity here. - - let mut builder = ChainBuilder::default(); - - let mut queue1: std::collections::VecDeque<_> = list1.into(); - let mut queue2: std::collections::VecDeque<_> = list2.into(); - - // The first unconsumed update in each block, via (k_idx, v_idx, t_idx), or None if exhausted. - // These are (0,0,0) for a new block, and should become None once there are no remaining updates. - let mut cursor1 = queue1.pop_front().map(|b| ((0,0,0), b)); - let mut cursor2 = queue2.pop_front().map(|b| ((0,0,0), b)); - - // For each pair of batches - while cursor1.is_some() && cursor2.is_some() { - Self::merge_batch(&mut cursor1, &mut cursor2, &mut builder, stash); - if cursor1.is_none() { cursor1 = queue1.pop_front().map(|b| ((0,0,0), b)); } - if cursor2.is_none() { cursor2 = queue2.pop_front().map(|b| ((0,0,0), b)); } - } - - // TODO: create batch for the non-empty cursor. - if let Some(((k,v,t),batch)) = cursor1 { - let mut out_batch = stash.pop().unwrap_or_default(); - let empty: Updates = Default::default(); - write_from_surveys( - &batch, - &empty, - &[Report::This(0, 1)], - &[Report::This(k, batch.keys.values.len())], - &[Report::This(v, batch.vals.values.len())], - &[Report::This(t, batch.times.values.len())], - &mut out_batch, - ); - builder.push(out_batch); - } - if let Some(((k,v,t),batch)) = cursor2 { - let mut out_batch = stash.pop().unwrap_or_default(); - let empty: Updates = Default::default(); - write_from_surveys( - &empty, - &batch, - &[Report::That(0, 1)], - &[Report::That(k, batch.keys.values.len())], - &[Report::That(v, batch.vals.values.len())], - &[Report::That(t, batch.times.values.len())], - &mut out_batch, - ); - builder.push(out_batch); - } - - builder.extend(queue1); - builder.extend(queue2); - *output = builder.done(); - // TODO: Tidy output to satisfy structural invariants. - } - - /// Merge two batches, one completely and another through the corresponding prefix. - /// - /// Each invocation determines the maximum amount of both batches we can merge, determined - /// by comparing the elements at the tails of each batch, and locating the lesser in other. - /// We will merge the whole of the batch containing the lesser, and the prefix up through - /// the lesser element in the other batch, setting the cursor to the first element strictly - /// greater than that lesser element. - /// - /// The algorithm uses a list of `Report` findings to map the interleavings of the layers. - /// Each indicates either a range exclusive to one of the inputs, or a one element common - /// to the layers from both inputs, which must be further explored. This map would normally - /// allow the full merge to happen, but we need to carefully start at each cursor, and end - /// just before the first element greater than the lesser bound. - /// - /// The consumed prefix and disjoint suffix should be single report entries, and it seems - /// fine to first produce all reports and then reflect on the cursors, rather than use the - /// cursors as part of the mapping. - #[inline(never)] - fn merge_batch( - batch1: &mut Option<((usize, usize, usize), Updates)>, - batch2: &mut Option<((usize, usize, usize), Updates)>, - builder: &mut ChainBuilder, - stash: &mut Vec>, - ) { - let ((k0_idx, v0_idx, t0_idx), updates0) = batch1.take().unwrap(); - let ((k1_idx, v1_idx, t1_idx), updates1) = batch2.take().unwrap(); - - use columnar::Borrow; - let keys0 = updates0.keys.borrow(); - let keys1 = updates1.keys.borrow(); - let vals0 = updates0.vals.borrow(); - let vals1 = updates1.vals.borrow(); - let times0 = updates0.times.borrow(); - let times1 = updates1.times.borrow(); - - // Survey the interleaving of the two inputs. - let mut key_survey = survey::>(keys0, keys1, &[Report::Both(0,0)]); - let mut val_survey = survey::>(vals0, vals1, &key_survey); - let mut time_survey = survey::>(times0, times1, &val_survey); - - // We now know enough to start writing into an output batch. - // We should update the input surveys to reflect the subset - // of data that we want. - // - // At most one cursor should be non-zero (assert!). - // A non-zero cursor must correspond to the first entry of the surveys, - // as there is at least one consumed update that precedes the other batch. - // We need to nudge that report forward to align with the cursor, potentially - // squeezing the report to nothing (to the upper bound). - - // We start by updating the surveys to reflect the cursors. - // If either cursor is set, then its batch has an element strictly less than the other batch. - // We therefore expect to find a prefix of This/That at the start of the survey. - if (k0_idx, v0_idx, t0_idx) != (0,0,0) { - let mut done = false; while !done { if let Report::This(l,u) = &mut key_survey[0] { if *u <= k0_idx { key_survey.remove(0); } else { *l = k0_idx; done = true; } } else { done = true; } } - let mut done = false; while !done { if let Report::This(l,u) = &mut val_survey[0] { if *u <= v0_idx { val_survey.remove(0); } else { *l = v0_idx; done = true; } } else { done = true; } } - let mut done = false; while !done { if let Report::This(l,u) = &mut time_survey[0] { if *u <= t0_idx { time_survey.remove(0); } else { *l = t0_idx; done = true; } } else { done = true; } } - } - - if (k1_idx, v1_idx, t1_idx) != (0,0,0) { - let mut done = false; while !done { if let Report::That(l,u) = &mut key_survey[0] { if *u <= k1_idx { key_survey.remove(0); } else { *l = k1_idx; done = true; } } else { done = true; } } - let mut done = false; while !done { if let Report::That(l,u) = &mut val_survey[0] { if *u <= v1_idx { val_survey.remove(0); } else { *l = v1_idx; done = true; } } else { done = true; } } - let mut done = false; while !done { if let Report::That(l,u) = &mut time_survey[0] { if *u <= t1_idx { time_survey.remove(0); } else { *l = t1_idx; done = true; } } else { done = true; } } - } - - // We want to trim the tails of the surveys to only cover ranges present in both inputs. - // We can determine which was "longer" by looking at the last entry of the bottom layer, - // which tells us which input (or both) contained the last element. - // - // From the bottom layer up, we'll identify the index of the last item, and then determine - // the index of the list it belongs to. We use that index in the next layer, to locate the - // index of the list it belongs to, on upward. - let next_cursor = match time_survey.last().unwrap() { - Report::This(_,_) => { - // Collect the last value indexes known to strictly exceed an entry in the other batch. - let mut t = times0.values.len(); - while let Some(Report::This(l,_)) = time_survey.last() { t = *l; time_survey.pop(); } - let mut v = vals0.values.len(); - while let Some(Report::This(l,_)) = val_survey.last() { v = *l; val_survey.pop(); } - let mut k = keys0.values.len(); - while let Some(Report::This(l,_)) = key_survey.last() { k = *l; key_survey.pop(); } - // Now we may need to correct by nudging down. - if v == times0.len() || times0.bounds.bounds(v).0 > t { v -= 1; } - if k == vals0.len() || vals0.bounds.bounds(k).0 > v { k -= 1; } - Some(Ok((k,v,t))) - } - Report::Both(_,_) => { None } - Report::That(_,_) => { - // Collect the last value indexes known to strictly exceed an entry in the other batch. - let mut t = times1.values.len(); - while let Some(Report::That(l,_)) = time_survey.last() { t = *l; time_survey.pop(); } - let mut v = vals1.values.len(); - while let Some(Report::That(l,_)) = val_survey.last() { v = *l; val_survey.pop(); } - let mut k = keys1.values.len(); - while let Some(Report::That(l,_)) = key_survey.last() { k = *l; key_survey.pop(); } - // Now we may need to correct by nudging down. - if v == times1.len() || times1.bounds.bounds(v).0 > t { v -= 1; } - if k == vals1.len() || vals1.bounds.bounds(k).0 > v { k -= 1; } - Some(Err((k,v,t))) - } - }; - - // Having updated the surveys, we now copy over the ranges they identify. - let mut out_batch = stash.pop().unwrap_or_default(); - // TODO: We should be able to size `out_batch` pretty accurately from the survey. - write_from_surveys(&updates0, &updates1, &[Report::Both(0,0)], &key_survey, &val_survey, &time_survey, &mut out_batch); - builder.push(out_batch); - - match next_cursor { - Some(Ok(kvt)) => { *batch1 = Some((kvt, updates0)); } - Some(Err(kvt)) => {*batch2 = Some((kvt, updates1)); } - None => { } - } - } - - } - - /// Write merged output from four levels of survey reports. - /// - /// Each layer is written independently: `write_layer` handles keys, vals, - /// and times; `write_diffs` handles diff consolidation. - #[inline(never)] - fn write_from_surveys( - updates0: &Updates, - updates1: &Updates, - root_survey: &[Report], - key_survey: &[Report], - val_survey: &[Report], - time_survey: &[Report], - output: &mut Updates, - ) { - use columnar::Borrow; - - write_layer(updates0.keys.borrow(), updates1.keys.borrow(), root_survey, key_survey, &mut output.keys); - write_layer(updates0.vals.borrow(), updates1.vals.borrow(), key_survey, val_survey, &mut output.vals); - write_layer(updates0.times.borrow(), updates1.times.borrow(), val_survey, time_survey, &mut output.times); - write_diffs::(updates0.diffs.borrow(), updates1.diffs.borrow(), time_survey, &mut output.diffs); - } - - /// From two sequences of interleaved lists, map out the interleaving of their values. - /// - /// The sequence of input reports identify constraints on the sorted order of lists in the two inputs, - /// callout out ranges of each that are exclusively order, and elements that have equal prefixes and - /// therefore "overlap" and should be further investigated through the values of the lists. - /// - /// The output should have the same form but for the next layer: subject to the ordering of `reports`, - /// a similar report for the values of the two lists, appropriate for the next layer. - #[inline(never)] - pub fn survey<'a, C: columnar::Container: Ord>>( - lists0: as columnar::Borrow>::Borrowed<'a>, - lists1: as columnar::Borrow>::Borrowed<'a>, - reports: &[Report], - ) -> Vec { - use columnar::Index; - let mut output = Vec::with_capacity(reports.len()); // may grow larger, but at least this large. - for report in reports.iter() { - match report { - Report::This(lower0, upper0) => { - let (new_lower, _) = lists0.bounds.bounds(*lower0); - let (_, new_upper) = lists0.bounds.bounds(*upper0-1); - output.push(Report::This(new_lower, new_upper)); - } - Report::Both(index0, index1) => { - - // Fetch the bounds from the layers. - let (mut lower0, upper0) = lists0.bounds.bounds(*index0); - let (mut lower1, upper1) = lists1.bounds.bounds(*index1); - - // Scour the intersecting range for matches. - while lower0 < upper0 && lower1 < upper1 { - let val0 = lists0.values.get(lower0); - let val1 = lists1.values.get(lower1); - match val0.cmp(&val1) { - std::cmp::Ordering::Less => { - let start = lower0; - lower0 += 1; - gallop(lists0.values, &mut lower0, upper0, |x| x < val1); - output.push(Report::This(start, lower0)); - }, - std::cmp::Ordering::Equal => { - output.push(Report::Both(lower0, lower1)); - lower0 += 1; - lower1 += 1; - }, - std::cmp::Ordering::Greater => { - let start = lower1; - lower1 += 1; - gallop(lists1.values, &mut lower1, upper1, |x| x < val0); - output.push(Report::That(start, lower1)); - }, - } - } - if lower0 < upper0 { output.push(Report::This(lower0, upper0)); } - if lower1 < upper1 { output.push(Report::That(lower1, upper1)); } - - } - Report::That(lower1, upper1) => { - let (new_lower, _) = lists1.bounds.bounds(*lower1); - let (_, new_upper) = lists1.bounds.bounds(*upper1-1); - output.push(Report::That(new_lower, new_upper)); - } - } - } - - output - } - - /// Write one layer of merged output from a list survey and item survey. - /// - /// The list survey describes which lists to produce (from the layer above). - /// The item survey describes how the items within those lists interleave. - /// Both surveys are consumed completely; a mismatch is a bug. - /// - /// Pruning (from cursor adjustments) can affect the first and last list - /// survey entries: the item survey's ranges may not match the natural - /// bounds of those lists. Middle entries are guaranteed unpruned and can - /// be bulk-copied. - #[inline(never)] - pub fn write_layer<'a, C: columnar::Container: Ord>>( - lists0: as columnar::Borrow>::Borrowed<'a>, - lists1: as columnar::Borrow>::Borrowed<'a>, - list_survey: &[Report], - item_survey: &[Report], - output: &mut crate::updates::Lists, - ) { - use columnar::{Container, Index, Len, Push}; - - let mut item_idx = 0; - - for (pos, list_report) in list_survey.iter().enumerate() { - let is_first = pos == 0; - let is_last = pos == list_survey.len() - 1; - let may_be_pruned = is_first || is_last; - - match list_report { - Report::This(lo, hi) => { - let Report::This(item_lo, item_hi) = item_survey[item_idx] else { unreachable!("Expected This in item survey for This list") }; - item_idx += 1; - if may_be_pruned { - // Item range may not match natural bounds; copy items in bulk - // but compute per-list bounds from natural bounds clamped to - // the item range. - let base = output.values.len(); - output.values.extend_from_self(lists0.values, item_lo..item_hi); - for i in *lo..*hi { - let (_, nat_hi) = lists0.bounds.bounds(i); - output.bounds.push((base + nat_hi.min(item_hi) - item_lo) as u64); - } - } else { - output.extend_from_self(lists0, *lo..*hi); - } - } - Report::That(lo, hi) => { - let Report::That(item_lo, item_hi) = item_survey[item_idx] else { unreachable!("Expected That in item survey for That list") }; - item_idx += 1; - if may_be_pruned { - let base = output.values.len(); - output.values.extend_from_self(lists1.values, item_lo..item_hi); - for i in *lo..*hi { - let (_, nat_hi) = lists1.bounds.bounds(i); - output.bounds.push((base + nat_hi.min(item_hi) - item_lo) as u64); - } - } else { - output.extend_from_self(lists1, *lo..*hi); - } - } - Report::Both(i0, i1) => { - // Merge: consume item survey entries until both sides are covered. - let (mut c0, end0) = lists0.bounds.bounds(*i0); - let (mut c1, end1) = lists1.bounds.bounds(*i1); - while (c0 < end0 || c1 < end1) && item_idx < item_survey.len() { - match item_survey[item_idx] { - Report::This(lo, hi) => { - if lo >= end0 { break; } - output.values.extend_from_self(lists0.values, lo..hi); - c0 = hi; - } - Report::That(lo, hi) => { - if lo >= end1 { break; } - output.values.extend_from_self(lists1.values, lo..hi); - c1 = hi; - } - Report::Both(v0, v1) => { - if v0 >= end0 && v1 >= end1 { break; } - output.values.push(lists0.values.get(v0)); - c0 = v0 + 1; - c1 = v1 + 1; - } - } - item_idx += 1; - } - output.bounds.push(output.values.len() as u64); - } - } - } - } - - /// Write the diff layer from a time survey and two diff inputs. - /// - /// The time survey is the item-level survey for the time layer, which - /// doubles as the list survey for diffs (one diff list per time entry). - /// - /// - `This(lo, hi)`: bulk-copy diff lists from input 0. - /// - `That(lo, hi)`: bulk-copy diff lists from input 1. - /// - `Both(t0, t1)`: consolidate the two singleton diffs. Push `[sum]` - /// if non-zero, or an empty list `[]` if they cancel. - #[inline(never)] - pub fn write_diffs( - diffs0: > as columnar::Borrow>::Borrowed<'_>, - diffs1: > as columnar::Borrow>::Borrowed<'_>, - time_survey: &[Report], - output: &mut crate::updates::Lists>, - ) { - use columnar::{Columnar, Container, Index, Len, Push}; - use differential_dataflow::difference::{Semigroup, IsZero}; - - for report in time_survey.iter() { - match report { - Report::This(lo, hi) => { output.extend_from_self(diffs0, *lo..*hi); } - Report::That(lo, hi) => { output.extend_from_self(diffs1, *lo..*hi); } - Report::Both(t0, t1) => { - // Read singleton diffs via list bounds, consolidate. - let (d0_lo, d0_hi) = diffs0.bounds.bounds(*t0); - let (d1_lo, d1_hi) = diffs1.bounds.bounds(*t1); - assert_eq!(d0_hi - d0_lo, 1, "Expected singleton diff list at t0={t0}"); - assert_eq!(d1_hi - d1_lo, 1, "Expected singleton diff list at t1={t1}"); - let mut diff: U::Diff = Columnar::into_owned(diffs0.values.get(d0_lo)); - diff.plus_equals(&Columnar::into_owned(diffs1.values.get(d1_lo))); - if !diff.is_zero() { output.values.push(&diff); } - output.bounds.push(output.values.len() as u64); - } - } - } - } - - /// Increments `index` until just after the last element of `input` to satisfy `cmp`. - /// - /// The method assumes that `cmp` is monotonic, never becoming true once it is false. - /// If an `upper` is supplied, it acts as a constraint on the interval of `input` explored. - #[inline(always)] - pub(crate) fn gallop(input: C, lower: &mut usize, upper: usize, mut cmp: impl FnMut(::Ref) -> bool) { - // if empty input, or already >= element, return - if *lower < upper && cmp(input.get(*lower)) { - let mut step = 1; - while *lower + step < upper && cmp(input.get(*lower + step)) { - *lower += step; - step <<= 1; - } - - step >>= 1; - while step > 0 { - if *lower + step < upper && cmp(input.get(*lower + step)) { - *lower += step; - } - step >>= 1; - } - - *lower += 1; - } - } - - /// A report we would expect to see in a sequence about two layers. - /// - /// A sequence of these reports reveal an ordered traversal of the keys - /// of two layers, with ranges exclusive to one, ranges exclusive to the - /// other, and individual elements (not ranges) common to both. - #[derive(Copy, Clone, Columnar, Debug)] - pub enum Report { - /// Range of indices in this input. - This(usize, usize), - /// Range of indices in that input. - That(usize, usize), - /// Matching indices in both inputs. - Both(usize, usize), - } - - pub struct ChainBuilder { - updates: Vec>, - } - - impl Default for ChainBuilder { fn default() -> Self { Self { updates: Default::default() } } } - - impl ChainBuilder { - fn push(&mut self, mut link: Updates) { - link = link.filter_zero(); - if link.len() > 0 { - if let Some(last) = self.updates.last_mut() { - if last.len() + link.len() < 2 * 64 * 1024 { - let mut build = crate::updates::UpdatesBuilder::new_from(std::mem::take(last)); - build.meld(&link); - *last = build.done(); - } - else { self.updates.push(link); } - - } - else { self.updates.push(link); } - } - } - fn extend(&mut self, iter: impl IntoIterator>) { for link in iter { self.push(link); }} - fn done(self) -> Vec> { self.updates } - } - } - - use builder::ValMirror; - pub mod builder { - - use differential_dataflow::trace::implementations::ord_neu::{Vals, Upds}; - use differential_dataflow::trace::implementations::ord_neu::val_batch::{OrdValBatch, OrdValStorage}; - use differential_dataflow::trace::Description; - - use crate::Updates; - use crate::layout::ColumnarUpdate as Update; - use crate::layout::ColumnarLayout as Layout; - use crate::arrangement::Coltainer; - - use columnar::{Borrow, IndexAs}; - use columnar::primitive::offsets::Strides; - use differential_dataflow::trace::implementations::OffsetList; - fn strides_to_offset_list(bounds: &Strides, count: usize) -> OffsetList { - let mut output = OffsetList::with_capacity(count); - output.push(0); - let bounds_b = bounds.borrow(); - for i in 0..count { - output.push(bounds_b.index_as(i) as usize); - } - output - } - - pub struct ValMirror { - chunks: Vec>, - } - impl differential_dataflow::trace::Builder for ValMirror { - type Time = U::Time; - type Input = Updates; - type Output = OrdValBatch>; - - fn with_capacity(_keys: usize, _vals: usize, _upds: usize) -> Self { - Self { chunks: Vec::new() } - } - fn push(&mut self, chunk: &mut Self::Input) { - if chunk.len() > 0 { - self.chunks.push(std::mem::take(chunk)); - } - } - fn done(self, description: Description) -> Self::Output { - let mut chain = self.chunks; - Self::seal(&mut chain, description) - } - fn seal(chain: &mut Vec, description: Description) -> Self::Output { - use columnar::Len; - - // Meld sorted, consolidated chain entries in order. - // Pre-allocate to avoid reallocations during meld. - use columnar::{Borrow, Container}; - let mut updates = Updates::::default(); - updates.keys.reserve_for(chain.iter().map(|c| c.keys.borrow())); - updates.vals.reserve_for(chain.iter().map(|c| c.vals.borrow())); - updates.times.reserve_for(chain.iter().map(|c| c.times.borrow())); - updates.diffs.reserve_for(chain.iter().map(|c| c.diffs.borrow())); - let mut builder = crate::updates::UpdatesBuilder::new_from(updates); - for chunk in chain.iter() { - builder.meld(chunk); - } - let merged = builder.done(); - chain.clear(); - - let updates = Len::len(&merged.diffs.values); - if updates == 0 { - let storage = OrdValStorage { - keys: Default::default(), - vals: Default::default(), - upds: Default::default(), - }; - OrdValBatch { storage, description, updates: 0 } - } else { - let val_offs = strides_to_offset_list(&merged.vals.bounds, Len::len(&merged.keys.values)); - let time_offs = strides_to_offset_list(&merged.times.bounds, Len::len(&merged.vals.values)); - let storage = OrdValStorage { - keys: Coltainer { container: merged.keys.values }, - vals: Vals { - offs: val_offs, - vals: Coltainer { container: merged.vals.values }, - }, - upds: Upds { - offs: time_offs, - times: Coltainer { container: merged.times.values }, - diffs: Coltainer { container: merged.diffs.values }, - }, - }; - OrdValBatch { storage, description, updates } - } - } - } - - } -} - -pub mod updates { - - use columnar::{Columnar, Container, ContainerOf, Vecs, Borrow, Index, IndexAs, Len, Push}; - use columnar::primitive::offsets::Strides; - use differential_dataflow::difference::{Semigroup, IsZero}; - - use crate::layout::ColumnarUpdate as Update; - - /// A `Vecs` using strided offsets. - pub type Lists = Vecs; - - /// Trie-structured update storage using columnar containers. - /// - /// Four nested layers of `Lists`: - /// - `keys`: lists of keys (outer lists are independent groups) - /// - `vals`: per-key, lists of vals - /// - `times`: per-val, lists of times - /// - `diffs`: per-time, lists of diffs (singletons when consolidated) - /// - /// A flat unsorted input has stride 1 at every level (one key per entry, - /// one val per key, one time per val, one diff per time). - /// A fully consolidated trie has a single outer key list, all lists sorted - /// and deduplicated, and singleton diff lists. - pub struct Updates { - pub keys: Lists>, - pub vals: Lists>, - pub times: Lists>, - pub diffs: Lists>, - } - - impl Default for Updates { - fn default() -> Self { - Self { - keys: Default::default(), - vals: Default::default(), - times: Default::default(), - diffs: Default::default(), - } - } - } - - impl std::fmt::Debug for Updates { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Updates").finish() - } - } - - impl Clone for Updates { - fn clone(&self) -> Self { - Self { - keys: self.keys.clone(), - vals: self.vals.clone(), - times: self.times.clone(), - diffs: self.diffs.clone(), - } - } - } - - pub type Tuple = (::Key, ::Val, ::Time, ::Diff); - - /// Returns the value-index range for list `i` given cumulative bounds. - #[inline] - pub fn child_range>(bounds: B, i: usize) -> std::ops::Range { - let lower = if i == 0 { 0 } else { bounds.index_as(i - 1) as usize }; - let upper = bounds.index_as(i) as usize; - lower..upper - } - - /// A streaming consolidation iterator for sorted `(key, val, time, diff)` data. - /// - /// Accumulates diffs for equal `(key, val, time)` triples, yielding at most - /// one output per distinct triple, with a non-zero accumulated diff. - /// Input must be sorted by `(key, val, time)`. - pub struct Consolidating { - iter: std::iter::Peekable, - diff: D, - } - - impl Consolidating - where - K: Copy + Eq, - V: Copy + Eq, - T: Copy + Eq, - D: Semigroup + IsZero + Default, - I: Iterator, - { - pub fn new(iter: I) -> Self { - Self { iter: iter.peekable(), diff: D::default() } - } - } - - impl Iterator for Consolidating - where - K: Copy + Eq, - V: Copy + Eq, - T: Copy + Eq, - D: Semigroup + IsZero + Default + Clone, - I: Iterator, - { - type Item = (K, V, T, D); - fn next(&mut self) -> Option { - loop { - let (k, v, t, d) = self.iter.next()?; - self.diff = d; - while let Some(&(k2, v2, t2, _)) = self.iter.peek() { - if k2 == k && v2 == v && t2 == t { - let (_, _, _, d2) = self.iter.next().unwrap(); - self.diff.plus_equals(&d2); - } else { - break; - } - } - if !self.diff.is_zero() { - return Some((k, v, t, self.diff.clone())); - } - } - } - } - - impl Updates { - - pub fn vals_bounds(&self, key_range: std::ops::Range) -> std::ops::Range { - if !key_range.is_empty() { - let bounds = self.vals.bounds.borrow(); - let lower = if key_range.start == 0 { 0 } else { bounds.index_as(key_range.start - 1) as usize }; - let upper = bounds.index_as(key_range.end - 1) as usize; - lower..upper - } else { key_range } - } - pub fn times_bounds(&self, val_range: std::ops::Range) -> std::ops::Range { - if !val_range.is_empty() { - let bounds = self.times.bounds.borrow(); - let lower = if val_range.start == 0 { 0 } else { bounds.index_as(val_range.start - 1) as usize }; - let upper = bounds.index_as(val_range.end - 1) as usize; - lower..upper - } else { val_range } - } - pub fn diffs_bounds(&self, time_range: std::ops::Range) -> std::ops::Range { - if !time_range.is_empty() { - let bounds = self.diffs.bounds.borrow(); - let lower = if time_range.start == 0 { 0 } else { bounds.index_as(time_range.start - 1) as usize }; - let upper = bounds.index_as(time_range.end - 1) as usize; - lower..upper - } else { time_range } - } - - /// Copies `other[key_range]` into self, keys and all. - pub fn extend_from_keys(&mut self, other: &Self, key_range: std::ops::Range) { - self.keys.values.extend_from_self(other.keys.values.borrow(), key_range.clone()); - self.vals.extend_from_self(other.vals.borrow(), key_range.clone()); - let val_range = other.vals_bounds(key_range); - self.times.extend_from_self(other.times.borrow(), val_range.clone()); - let time_range = other.times_bounds(val_range); - self.diffs.extend_from_self(other.diffs.borrow(), time_range); - } - - /// Copies a range of vals (with their times and diffs) from `other` into self. - pub fn extend_from_vals(&mut self, other: &Self, val_range: std::ops::Range) { - self.vals.values.extend_from_self(other.vals.values.borrow(), val_range.clone()); - self.times.extend_from_self(other.times.borrow(), val_range.clone()); - let time_range = other.times_bounds(val_range); - self.diffs.extend_from_self(other.diffs.borrow(), time_range); - } - - /// Forms a consolidated `Updates` trie from unsorted `(key, val, time, diff)` refs. - pub fn form_unsorted<'a>(unsorted: impl Iterator>>) -> Self { - let mut data = unsorted.collect::>(); - data.sort(); - Self::form(data.into_iter()) - } - - /// Forms a consolidated `Updates` trie from sorted `(key, val, time, diff)` refs. - pub fn form<'a>(sorted: impl Iterator>>) -> Self { - - // Step 1: Streaming consolidation — accumulate diffs, drop zeros. - let consolidated = Consolidating::new( - sorted.map(|(k, v, t, d)| (k, v, t, ::into_owned(d))) - ); - - // Step 2: Build the trie from consolidated, sorted, non-zero data. - let mut output = Self::default(); - let mut updates = consolidated; - if let Some((key, val, time, diff)) = updates.next() { - let mut prev = (key, val, time); - output.keys.values.push(key); - output.vals.values.push(val); - output.times.values.push(time); - output.diffs.values.push(&diff); - output.diffs.bounds.push(output.diffs.values.len() as u64); - - // As we proceed, seal up known complete runs. - for (key, val, time, diff) in updates { - - // If keys differ, record key and seal vals and times. - if key != prev.0 { - output.vals.bounds.push(output.vals.values.len() as u64); - output.times.bounds.push(output.times.values.len() as u64); - output.keys.values.push(key); - output.vals.values.push(val); - } - // If vals differ, record val and seal times. - else if val != prev.1 { - output.times.bounds.push(output.times.values.len() as u64); - output.vals.values.push(val); - } - else { - // We better not find a duplicate time. - assert!(time != prev.2); - } - - // Always record (time, diff). - output.times.values.push(time); - output.diffs.values.push(&diff); - output.diffs.bounds.push(output.diffs.values.len() as u64); - - prev = (key, val, time); - } - - // Seal up open lists. - output.keys.bounds.push(output.keys.values.len() as u64); - output.vals.bounds.push(output.vals.values.len() as u64); - output.times.bounds.push(output.times.values.len() as u64); - } - - output - } - - /// Consolidates into canonical trie form: - /// single outer key list, all lists sorted and deduplicated, - /// diff lists are singletons (or absent if cancelled). - pub fn consolidate(self) -> Self { Self::form_unsorted(self.iter()) } - pub fn filter_zero(self) -> Self { Self::form(self.iter()) } - - /// The number of leaf-level diff entries (total updates). - pub fn len(&self) -> usize { self.diffs.values.len() } - } - - /// Push a single flat update as a stride-1 entry. - /// - /// Each field is independently typed — columnar refs, `&Owned`, owned values, - /// or any other type the column container accepts via its `Push` impl. - impl Push<(KP, VP, TP, DP)> for Updates - where - ContainerOf: Push, - ContainerOf: Push, - ContainerOf: Push, - ContainerOf: Push, - { - fn push(&mut self, (key, val, time, diff): (KP, VP, TP, DP)) { - self.keys.values.push(key); - self.keys.bounds.push(self.keys.values.len() as u64); - self.vals.values.push(val); - self.vals.bounds.push(self.vals.values.len() as u64); - self.times.values.push(time); - self.times.bounds.push(self.times.values.len() as u64); - self.diffs.values.push(diff); - self.diffs.bounds.push(self.diffs.values.len() as u64); - } - } - - /// PushInto for the `((K, V), T, R)` shape that reduce_trace uses. - impl timely::container::PushInto<((U::Key, U::Val), U::Time, U::Diff)> for Updates { - fn push_into(&mut self, ((key, val), time, diff): ((U::Key, U::Val), U::Time, U::Diff)) { - self.push((&key, &val, &time, &diff)); - } - } - - impl Updates { - - /// Iterate all `(key, val, time, diff)` entries as refs. - pub fn iter(&self) -> impl Iterator, - columnar::Ref<'_, U::Val>, - columnar::Ref<'_, U::Time>, - columnar::Ref<'_, U::Diff>, - )> { - let keys_b = self.keys.borrow(); - let vals_b = self.vals.borrow(); - let times_b = self.times.borrow(); - let diffs_b = self.diffs.borrow(); - - (0..Len::len(&keys_b)) - .flat_map(move |outer| child_range(keys_b.bounds, outer)) - .flat_map(move |k| { - let key = keys_b.values.get(k); - child_range(vals_b.bounds, k).map(move |v| (key, v)) - }) - .flat_map(move |(key, v)| { - let val = vals_b.values.get(v); - child_range(times_b.bounds, v).map(move |t| (key, val, t)) - }) - .flat_map(move |(key, val, t)| { - let time = times_b.values.get(t); - child_range(diffs_b.bounds, t).map(move |d| (key, val, time, diffs_b.values.get(d))) - }) - } - } - - impl timely::Accountable for Updates { - #[inline] fn record_count(&self) -> i64 { Len::len(&self.diffs.values) as i64 } - } - - impl timely::dataflow::channels::ContainerBytes for Updates { - fn from_bytes(_bytes: timely::bytes::arc::Bytes) -> Self { unimplemented!() } - fn length_in_bytes(&self) -> usize { unimplemented!() } - fn into_bytes(&self, _writer: &mut W) { unimplemented!() } - } - - /// An incremental trie builder that accepts sorted, consolidated `Updates` chunks - /// and melds them into a single `Updates` trie. - /// - /// The internal `Updates` has open (unsealed) bounds at the keys, vals, and times - /// levels — the last group at each level has its values pushed but no corresponding - /// bounds entry. `diffs.bounds` is always 1:1 with `times.values`. - /// - /// `meld` accepts a consolidated `Updates` whose first `(key, val, time)` is - /// strictly greater than the builder's last `(key, val, time)`. The key and val - /// may equal the builder's current open key/val, as long as the time is greater. - /// - /// `done` seals all open bounds and returns the completed `Updates`. - pub struct UpdatesBuilder { - /// Non-empty, consolidated updates. - updates: Updates, - } - - impl UpdatesBuilder { - /// Construct a new builder from consolidated, sealed updates. - /// - /// Unseals the last group at keys, vals, and times levels so that - /// subsequent `meld` calls can extend the open groups. - /// If the updates are not consolidated none of this works. - pub fn new_from(mut updates: Updates) -> Self { - use columnar::Len; - if Len::len(&updates.keys.values) > 0 { - updates.keys.bounds.pop(); - updates.vals.bounds.pop(); - updates.times.bounds.pop(); - } - Self { updates } - } - - /// Meld a sorted, consolidated `Updates` chunk into this builder. - /// - /// The chunk's first `(key, val, time)` must be strictly greater than - /// the builder's last `(key, val, time)`. Keys and vals may overlap - /// (continue the current group), but times must be strictly increasing - /// within the same `(key, val)`. - pub fn meld(&mut self, chunk: &Updates) { - use columnar::{Borrow, Index, Len}; - - if chunk.len() == 0 { return; } - - // Empty builder: clone the chunk and unseal it. - if Len::len(&self.updates.keys.values) == 0 { - self.updates = chunk.clone(); - self.updates.keys.bounds.pop(); - self.updates.vals.bounds.pop(); - self.updates.times.bounds.pop(); - return; - } - - // Pre-compute boundary comparisons before mutating. - let keys_match = { - let skb = self.updates.keys.values.borrow(); - let ckb = chunk.keys.values.borrow(); - skb.get(Len::len(&skb) - 1) == ckb.get(0) - }; - let vals_match = keys_match && { - let svb = self.updates.vals.values.borrow(); - let cvb = chunk.vals.values.borrow(); - svb.get(Len::len(&svb) - 1) == cvb.get(0) - }; - - let chunk_num_keys = Len::len(&chunk.keys.values); - let chunk_num_vals = Len::len(&chunk.vals.values); - let chunk_num_times = Len::len(&chunk.times.values); - - // Child ranges for the first element at each level of the chunk. - let first_key_vals = child_range(chunk.vals.borrow().bounds, 0); - let first_val_times = child_range(chunk.times.borrow().bounds, 0); - - // There is a first position where coordinates disagree. - // Strictly beyond that position: seal bounds, extend lists, re-open the last bound. - // At that position: meld the first list, extend subsequent lists, re-open. - let mut differ = false; - - // --- Keys --- - if keys_match { - // Skip the duplicate first key; add remaining keys. - if chunk_num_keys > 1 { - self.updates.keys.values.extend_from_self(chunk.keys.values.borrow(), 1..chunk_num_keys); - } - } else { - // All keys are new. - self.updates.keys.values.extend_from_self(chunk.keys.values.borrow(), 0..chunk_num_keys); - differ = true; - } - - // --- Vals --- - if differ { - // Keys differed: seal open val group, extend all val lists, unseal last. - self.updates.vals.bounds.push(Len::len(&self.updates.vals.values) as u64); - self.updates.vals.extend_from_self(chunk.vals.borrow(), 0..chunk_num_keys); - self.updates.vals.bounds.pop(); - } else { - // Keys matched: meld vals for the shared key. - if vals_match { - // Skip the duplicate first val; add remaining vals from the first key's list. - if first_key_vals.len() > 1 { - self.updates.vals.values.extend_from_self( - chunk.vals.values.borrow(), - (first_key_vals.start + 1)..first_key_vals.end, - ); - } - } else { - // First val differs: add all vals from the first key's list. - self.updates.vals.values.extend_from_self( - chunk.vals.values.borrow(), - first_key_vals.clone(), - ); - differ = true; - } - // Seal the matched key's val group, extend remaining keys' val lists, unseal. - if chunk_num_keys > 1 { - self.updates.vals.bounds.push(Len::len(&self.updates.vals.values) as u64); - self.updates.vals.extend_from_self(chunk.vals.borrow(), 1..chunk_num_keys); - self.updates.vals.bounds.pop(); - } - } - - // --- Times --- - if differ { - // Seal open time group, extend all time lists, unseal last. - self.updates.times.bounds.push(Len::len(&self.updates.times.values) as u64); - self.updates.times.extend_from_self(chunk.times.borrow(), 0..chunk_num_vals); - self.updates.times.bounds.pop(); - } else { - // Keys and vals matched. Times must be strictly greater (precondition), - // so we always set differ = true here. - debug_assert!({ - let stb = self.updates.times.values.borrow(); - let ctb = chunk.times.values.borrow(); - stb.get(Len::len(&stb) - 1) != ctb.get(0) - }, "meld: duplicate time within same (key, val)"); - // Add times from the first val's time list into the open group. - self.updates.times.values.extend_from_self( - chunk.times.values.borrow(), - first_val_times.clone(), - ); - differ = true; - // Seal the matched val's time group, extend remaining vals' time lists, unseal. - if chunk_num_vals > 1 { - self.updates.times.bounds.push(Len::len(&self.updates.times.values) as u64); - self.updates.times.extend_from_self(chunk.times.borrow(), 1..chunk_num_vals); - self.updates.times.bounds.pop(); - } - } - - // --- Diffs --- - // Diffs are always sealed (1:1 with times). By the precondition that - // times are strictly increasing for the same (key, val), differ is - // always true by this point — just extend all diff lists. - debug_assert!(differ); - self.updates.diffs.extend_from_self(chunk.diffs.borrow(), 0..chunk_num_times); - } - - /// Seal all open bounds and return the completed `Updates`. - pub fn done(mut self) -> Updates { - use columnar::Len; - if Len::len(&self.updates.keys.values) > 0 { - // Seal the open time group. - self.updates.times.bounds.push(Len::len(&self.updates.times.values) as u64); - // Seal the open val group. - self.updates.vals.bounds.push(Len::len(&self.updates.vals.values) as u64); - // Seal the outer key group. - self.updates.keys.bounds.push(Len::len(&self.updates.keys.values) as u64); - } - self.updates - } - } - - #[cfg(test)] - mod tests { - use super::*; - use columnar::Push; - - type TestUpdate = (u64, u64, u64, i64); - - fn collect(updates: &Updates) -> Vec<(u64, u64, u64, i64)> { - updates.iter().map(|(k, v, t, d)| (*k, *v, *t, *d)).collect() - } - - #[test] - fn test_push_and_consolidate_basic() { - let mut updates = Updates::::default(); - updates.push((&1, &10, &100, &1)); - updates.push((&1, &10, &100, &2)); - updates.push((&2, &20, &200, &5)); - assert_eq!(updates.len(), 3); - assert_eq!(collect(&updates.consolidate()), vec![(1, 10, 100, 3), (2, 20, 200, 5)]); - } - - #[test] - fn test_cancellation() { - let mut updates = Updates::::default(); - updates.push((&1, &10, &100, &3)); - updates.push((&1, &10, &100, &-3)); - updates.push((&2, &20, &200, &1)); - assert_eq!(collect(&updates.consolidate()), vec![(2, 20, 200, 1)]); - } - - #[test] - fn test_multiple_vals_and_times() { - let mut updates = Updates::::default(); - updates.push((&1, &10, &100, &1)); - updates.push((&1, &10, &200, &2)); - updates.push((&1, &20, &100, &3)); - updates.push((&1, &20, &100, &4)); - assert_eq!(collect(&updates.consolidate()), vec![(1, 10, 100, 1), (1, 10, 200, 2), (1, 20, 100, 7)]); - } - - #[test] - fn test_val_cancellation_propagates() { - let mut updates = Updates::::default(); - updates.push((&1, &10, &100, &5)); - updates.push((&1, &10, &100, &-5)); - updates.push((&1, &20, &100, &1)); - assert_eq!(collect(&updates.consolidate()), vec![(1, 20, 100, 1)]); - } - - #[test] - fn test_empty() { - let updates = Updates::::default(); - assert_eq!(collect(&updates.consolidate()), vec![]); - } - - #[test] - fn test_total_cancellation() { - let mut updates = Updates::::default(); - updates.push((&1, &10, &100, &1)); - updates.push((&1, &10, &100, &-1)); - assert_eq!(collect(&updates.consolidate()), vec![]); - } - - #[test] - fn test_unsorted_input() { - let mut updates = Updates::::default(); - updates.push((&3, &30, &300, &1)); - updates.push((&1, &10, &100, &2)); - updates.push((&2, &20, &200, &3)); - assert_eq!(collect(&updates.consolidate()), vec![(1, 10, 100, 2), (2, 20, 200, 3), (3, 30, 300, 1)]); - } - - #[test] - fn test_first_key_cancels() { - let mut updates = Updates::::default(); - updates.push((&1, &10, &100, &5)); - updates.push((&1, &10, &100, &-5)); - updates.push((&2, &20, &200, &3)); - assert_eq!(collect(&updates.consolidate()), vec![(2, 20, 200, 3)]); - } - - #[test] - fn test_middle_time_cancels() { - let mut updates = Updates::::default(); - updates.push((&1, &10, &100, &1)); - updates.push((&1, &10, &200, &2)); - updates.push((&1, &10, &200, &-2)); - updates.push((&1, &10, &300, &3)); - assert_eq!(collect(&updates.consolidate()), vec![(1, 10, 100, 1), (1, 10, 300, 3)]); - } - - #[test] - fn test_first_val_cancels() { - let mut updates = Updates::::default(); - updates.push((&1, &10, &100, &1)); - updates.push((&1, &10, &100, &-1)); - updates.push((&1, &20, &100, &5)); - assert_eq!(collect(&updates.consolidate()), vec![(1, 20, 100, 5)]); - } - - #[test] - fn test_interleaved_cancellations() { - let mut updates = Updates::::default(); - updates.push((&1, &10, &100, &1)); - updates.push((&1, &10, &100, &-1)); - updates.push((&2, &20, &200, &7)); - updates.push((&3, &30, &300, &4)); - updates.push((&3, &30, &300, &-4)); - assert_eq!(collect(&updates.consolidate()), vec![(2, 20, 200, 7)]); - } - } -} - -/// A columnar flat_map: iterates RecordedUpdates, calls logic per (key, val, time, diff), -/// joins output times with input times, multiplies output diffs with input diffs. -/// -/// This subsumes map, filter, negate, and enter_at for columnar collections. -pub fn join_function( - input: differential_dataflow::Collection>, - mut logic: L, -) -> differential_dataflow::Collection> -where - U::Time: differential_dataflow::lattice::Lattice, - U: layout::ColumnarUpdate>, - I: IntoIterator, - L: FnMut( - columnar::Ref<'_, U::Key>, - columnar::Ref<'_, U::Val>, - columnar::Ref<'_, U::Time>, - columnar::Ref<'_, U::Diff>, - ) -> I + 'static, -{ - use timely::dataflow::operators::generic::Operator; - use timely::dataflow::channels::pact::Pipeline; - use differential_dataflow::AsCollection; - use differential_dataflow::difference::Multiply; - use differential_dataflow::lattice::Lattice; - use columnar::Columnar; - - input - .inner - .unary::, _, _, _>(Pipeline, "JoinFunction", move |_, _| { - move |input, output| { - let mut t1o = U::Time::default(); - let mut d1o = U::Diff::default(); - input.for_each(|time, data| { - let mut session = output.session_with_builder(&time); - for (k1, v1, t1, d1) in data.updates.iter() { - Columnar::copy_from(&mut t1o, t1); - Columnar::copy_from(&mut d1o, d1); - for (k2, v2, t2, d2) in logic(k1, v1, t1, d1) { - let t3 = t2.join(&t1o); - let d3 = d2.multiply(&d1o); - session.give((&k2, &v2, &t3, &d3)); - } - } - }); - } - }) - .as_collection() -} - -type DynTime = timely::order::Product>; - -/// Leave a dynamic iterative scope, truncating PointStamp coordinates. -/// -/// Uses OperatorBuilder (not unary) for the custom input connection summary -/// that tells timely how the PointStamp is affected (retain `level - 1` coordinates). -/// -/// Consolidates after truncation since distinct PointStamp coordinates can collapse. -pub fn leave_dynamic( - input: differential_dataflow::Collection>, - level: usize, -) -> differential_dataflow::Collection> -where - K: columnar::Columnar, - V: columnar::Columnar, - R: columnar::Columnar, - (K, V, DynTime, R): layout::ColumnarUpdate, -{ - use timely::dataflow::channels::pact::Pipeline; - use timely::dataflow::operators::generic::builder_rc::OperatorBuilder; - use timely::dataflow::operators::generic::OutputBuilder; - use timely::order::Product; - use timely::progress::Antichain; - use timely::container::{ContainerBuilder, PushInto}; - use differential_dataflow::AsCollection; - use differential_dataflow::dynamic::pointstamp::{PointStamp, PointStampSummary}; - use columnar::Columnar; - - let mut builder = OperatorBuilder::new("LeaveDynamic".to_string(), input.inner.scope()); - let (output, stream) = builder.new_output(); - let mut output = OutputBuilder::from(output); - let mut op_input = builder.new_input_connection( - input.inner, - Pipeline, - [( - 0, - Antichain::from_elem(Product { - outer: Default::default(), - inner: PointStampSummary { - retain: Some(level - 1), - actions: Vec::new(), - }, - }), - )], - ); - - builder.build(move |_capability| { - let mut col_builder = ValColBuilder::<(K, V, DynTime, R)>::default(); - let mut time = DynTime::default(); - move |_frontier| { - let mut output = output.activate(); - op_input.for_each(|cap, data| { - // Truncate the capability's timestamp. - let mut new_time = cap.time().clone(); - let mut vec = std::mem::take(&mut new_time.inner).into_inner(); - vec.truncate(level - 1); - new_time.inner = PointStamp::new(vec); - let new_cap = cap.delayed(&new_time, 0); - // Push updates with truncated times into the builder. - // The builder's form call on flush sorts and consolidates, - // handling the duplicate times that truncation can produce. - // TODO: The input trie is already sorted; a streaming form - // that accepts pre-sorted, potentially-collapsing timestamps - // could avoid the re-sort inside the builder. - for (k, v, t, d) in data.updates.iter() { - Columnar::copy_from(&mut time, t); - let mut inner_vec = std::mem::take(&mut time.inner).into_inner(); - inner_vec.truncate(level - 1); - time.inner = PointStamp::new(inner_vec); - col_builder.push_into((k, v, &time, d)); - } - let mut session = output.session(&new_cap); - while let Some(container) = col_builder.finish() { - session.give_container(container); - } - }); - } - }); - - stream.as_collection() -} - -/// Extract a `Collection<_, RecordedUpdates>` from a columnar `Arranged`. -/// -/// Cursors through each batch and pushes `(key, val, time, diff)` refs into -/// a `ValColBuilder`, which sorts and consolidates on flush. -pub fn as_recorded_updates( - arranged: differential_dataflow::operators::arrange::Arranged< - differential_dataflow::operators::arrange::TraceAgent>, - >, -) -> differential_dataflow::Collection> -where - U: layout::ColumnarUpdate, -{ - use timely::dataflow::operators::generic::Operator; - use timely::dataflow::channels::pact::Pipeline; - use differential_dataflow::trace::{BatchReader, Cursor}; - use differential_dataflow::AsCollection; - - arranged.stream - .unary::, _, _, _>(Pipeline, "AsRecordedUpdates", |_, _| { - move |input, output| { - input.for_each(|time, batches| { - let mut session = output.session_with_builder(&time); - for batch in batches.drain(..) { - let mut cursor = batch.cursor(); - while cursor.key_valid(&batch) { - while cursor.val_valid(&batch) { - let key = cursor.key(&batch); - let val = cursor.val(&batch); - cursor.map_times(&batch, |time, diff| { - session.give((key, val, time, diff)); - }); - cursor.step_val(&batch); - } - cursor.step_key(&batch); - } - } - }); - } - }) - .as_collection() -} diff --git a/differential-dataflow/examples/columnar/main.rs b/differential-dataflow/examples/columnar/main.rs index 56380089c..f26d435b2 100644 --- a/differential-dataflow/examples/columnar/main.rs +++ b/differential-dataflow/examples/columnar/main.rs @@ -2,15 +2,13 @@ //! //! Demonstrates columnar-backed arrangements in an iterative scope, //! exercising Enter, Leave, Negate, ResultsIn on RecordedUpdates, -//! and Push on Updates for the reduce builder path. - -mod columnar_support; +//! and Push on UpdatesTyped for the reduce builder path. use timely::container::{ContainerBuilder, PushInto}; use timely::dataflow::InputHandle; use timely::dataflow::ProbeHandle; -use columnar_support::*; +use differential_dataflow::columnar::*; use mimalloc::MiMalloc; @@ -89,7 +87,7 @@ fn main() { /// /// This module exercises the container traits needed for iterative columnar /// computation: Enter, Leave, Negate, ResultsIn on RecordedUpdates, and -/// Push on Updates for the reduce builder path. +/// Push on UpdatesTyped for the reduce builder path. mod reachability { use timely::order::Product; @@ -99,7 +97,7 @@ mod reachability { use differential_dataflow::operators::arrange::arrangement::arrange_core; use differential_dataflow::operators::join::join_traces; - use crate::columnar_support::*; + use differential_dataflow::columnar::*; type Node = u32; type Time = u64; @@ -127,13 +125,15 @@ mod reachability { let edges_pact = ValPact { hashfunc: |k: columnar::Ref<'_, Node>| *k as u64 }; let reach_pact = ValPact { hashfunc: |k: columnar::Ref<'_, Node>| *k as u64 }; - let edges_arr = arrange_core::<_, + let edges_arr = arrange_core::<_, _, + ValChunker<(Node, Node, IterTime, Diff)>, ValBatcher, ValBuilder, ValSpine, >(edges_inner.inner, edges_pact, "Edges"); - let reach_arr = arrange_core::<_, + let reach_arr = arrange_core::<_, _, + ValChunker<(Node, (), IterTime, Diff)>, ValBatcher, ValBuilder, ValSpine, @@ -157,7 +157,8 @@ mod reachability { // Arrange for reduce. let combined_pact = ValPact { hashfunc: |k: columnar::Ref<'_, Node>| *k as u64 }; - let combined_arr = arrange_core::<_, + let combined_arr = arrange_core::<_, _, + ValChunker<(Node, (), IterTime, Diff)>, ValBatcher, ValBuilder, ValSpine, diff --git a/differential-dataflow/examples/columnar_spill.rs b/differential-dataflow/examples/columnar_spill.rs new file mode 100644 index 000000000..3c87b1d4a --- /dev/null +++ b/differential-dataflow/examples/columnar_spill.rs @@ -0,0 +1,694 @@ +//! Example: file-backed spill for the columnar `MergeBatcher`. +//! +//! Demonstrates `Spill` / `Fetch` / `SpillPolicy` impls modeled on TD's +//! `communication/examples/spill_stress.rs`. Spills `UpdatesTyped` chunks +//! to a tempfile via per-column `Stash::write_bytes`, fetches them back via +//! `Stash::try_from_bytes` and `Updates::into_typed`. +//! +//! Run with: `cargo run --example columnar_spill` + +use std::io::{Read, Seek, SeekFrom, Write}; +use std::marker::PhantomData; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +use mimalloc::MiMalloc; + +#[global_allocator] +static GLOBAL: MiMalloc = MiMalloc; + +// Static spill-policy config, read by `SpillBatcher::new` when `arrange_core` +// constructs each worker's batcher (we can't pass parameters through the +// `Batcher::new(logger, op_id)` constructor). +static ENABLE_SPILL: AtomicBool = AtomicBool::new(true); +static HEAD: AtomicUsize = AtomicUsize::new(10_000_000); +static THRESH: AtomicUsize = AtomicUsize::new(50_000_000); + +/// Cumulative bytes serialized (pre-compression) and bytes written +/// (post-compression) across all `FileSpill` instances. Lets us report a +/// compression ratio at the end of a run. +static BYTES_DECOMPRESSED: AtomicUsize = AtomicUsize::new(0); +static BYTES_COMPRESSED: AtomicUsize = AtomicUsize::new(0); + +/// Cross-worker registry of `Threshold` stats so we can sum them after a run. +static SHARED_STATS: OnceLock>>> = OnceLock::new(); + +fn register_stats(stats: Arc) { + SHARED_STATS + .get_or_init(|| Mutex::new(Vec::new())) + .lock() + .unwrap() + .push(stats); +} + +fn collect_stats() -> (usize, usize) { + if let Some(m) = SHARED_STATS.get() { + let v = m.lock().unwrap(); + let fires: usize = v.iter().map(|s| s.fires.load(Ordering::Relaxed)).sum(); + let chunks: usize = v.iter().map(|s| s.chunks_spilled.load(Ordering::Relaxed)).sum(); + (fires, chunks) + } else { + (0, 0) + } +} + +fn reset_stats() { + if let Some(m) = SHARED_STATS.get() { + m.lock().unwrap().clear(); + } + BYTES_DECOMPRESSED.store(0, Ordering::Relaxed); + BYTES_COMPRESSED.store(0, Ordering::Relaxed); +} + +use columnar::Push; +use columnar::bytes::stash::Stash; + +use differential_dataflow::columnar::{ValBuilder, ValChunker, ValColBuilder, ValSpine}; +use differential_dataflow::columnar::batcher::MergeBatcher; +use differential_dataflow::columnar::layout::ColumnarUpdate as Update; +use differential_dataflow::columnar::spill::{Entry, Fetch, Spill, SpillPolicy}; +use differential_dataflow::columnar::updates::{Updates, UpdatesTyped}; +use differential_dataflow::logging::Logger; +use differential_dataflow::operators::arrange::arrangement::arrange_core; +use differential_dataflow::trace::{Batcher, Description}; +use timely::dataflow::channels::pact::Pipeline; +use timely::dataflow::operators::probe::{Handle as ProbeHandle, Probe}; +use timely::dataflow::operators::Input; +use timely::dataflow::InputHandle; +use timely::container::PushInto; +use timely::progress::frontier::AntichainRef; +use timely::progress::{frontier::Antichain, Timestamp}; + +/// File-backed `Spill`. Serializes each chunk into a reusable `Vec` and +/// writes it with one `write_all` per chunk. Rotates to a new tempfile every +/// `ROTATE_AFTER_BYTES` so disk space is reclaimed as `FileFetch` handles are +/// consumed: once a file's last handle is dropped, the `Arc` hits zero, the +/// (already-unlinked) tempfile closes, and the OS gives the space back. +pub struct FileSpill { + /// Current write file. `None` until first spill, or after rotation if no + /// chunks have been written to a fresh file yet. + current: Option>>, + /// Bytes written to `current` so far. + current_offset: u64, + /// Reusable serialization buffer; grows to fit the largest chunk seen, + /// then sticks at that capacity (no per-chunk allocation). + buf: Vec, + _marker: PhantomData, +} + +impl FileSpill { + /// Rotate to a new tempfile after this many bytes. Sized so each file + /// holds many chunks (amortizing the file-open cost) but small enough + /// that we don't accumulate hundreds of GB on disk before any can be + /// reclaimed. + const ROTATE_AFTER_BYTES: u64 = 1 << 30; // 1 GiB + + pub fn new() -> std::io::Result { + Ok(Self { + current: None, + current_offset: 0, + buf: Vec::new(), + _marker: PhantomData, + }) + } + + fn current_file(&mut self) -> std::io::Result>> { + if self.current.is_none() || self.current_offset >= Self::ROTATE_AFTER_BYTES { + // Drop the previous Arc — outstanding `FileFetch` handles still + // hold it; once they're all consumed, the file is unlinked-closed + // and the OS reclaims its space. + self.current = Some(Arc::new(Mutex::new(tempfile::tempfile()?))); + self.current_offset = 0; + } + Ok(self.current.as_ref().unwrap().clone()) + } +} + +impl Spill> for FileSpill { + fn spill( + &mut self, + chunks: &mut Vec>, + handles: &mut Vec>>>, + ) { + while let Some(chunk) = chunks.pop() { + let updates: Updates> = chunk.into(); + let keys_len = updates.keys.length_in_bytes() as u64; + let vals_len = updates.vals.length_in_bytes() as u64; + let times_len = updates.times.length_in_bytes() as u64; + let diffs_len = updates.diffs.length_in_bytes() as u64; + let total = 32 + keys_len + vals_len + times_len + diffs_len; + + // Serialize the whole chunk (header + four columns) into the + // reusable buffer. + self.buf.clear(); + self.buf.extend_from_slice(&keys_len.to_le_bytes()); + self.buf.extend_from_slice(&vals_len.to_le_bytes()); + self.buf.extend_from_slice(×_len.to_le_bytes()); + self.buf.extend_from_slice(&diffs_len.to_le_bytes()); + updates.keys.write_bytes(&mut self.buf).unwrap(); + updates.vals.write_bytes(&mut self.buf).unwrap(); + updates.times.write_bytes(&mut self.buf).unwrap(); + updates.diffs.write_bytes(&mut self.buf).unwrap(); + debug_assert_eq!(self.buf.len() as u64, total); + + // Compress before writing. lz4 block format: caller is responsible + // for tracking the decompressed size, which we stash in the handle. + let compressed = lz4_flex::block::compress(&self.buf); + let comp_len = compressed.len() as u64; + BYTES_DECOMPRESSED.fetch_add(total as usize, Ordering::Relaxed); + BYTES_COMPRESSED.fetch_add(comp_len as usize, Ordering::Relaxed); + + let file = self.current_file().expect("tempfile"); + let start = self.current_offset; + let mut f = file.lock().unwrap(); + f.seek(SeekFrom::Start(start)).unwrap(); + f.write_all(&compressed).unwrap(); + drop(f); + self.current_offset += comp_len; + + handles.push(Box::new(FileFetch:: { + file: file.clone(), + offset: start, + compressed_len: comp_len, + decompressed_len: total, + _marker: PhantomData, + })); + } + } +} + +/// Per-chunk fetch handle. On `fetch`, reads `compressed_len` bytes at +/// `offset`, decompresses to `decompressed_len`, then parses the 32-byte +/// header + four column payloads. +pub struct FileFetch { + file: Arc>, + offset: u64, + compressed_len: u64, + decompressed_len: u64, + _marker: PhantomData, +} + +impl Fetch> for FileFetch { + fn fetch(self: Box) -> Result>, Box>>> { + // Read the compressed bytes in one shot. + let mut compressed = vec![0u8; self.compressed_len as usize]; + let mut file = self.file.lock().unwrap(); + file.seek(SeekFrom::Start(self.offset)).unwrap(); + file.read_exact(&mut compressed).unwrap(); + drop(file); + + let decompressed = lz4_flex::block::decompress(&compressed, self.decompressed_len as usize) + .expect("lz4 decompress"); + + // Parse the 32-byte header from the decompressed buffer. + let header = &decompressed[0..32]; + let keys_len = u64::from_le_bytes(header[0..8].try_into().unwrap()) as usize; + let vals_len = u64::from_le_bytes(header[8..16].try_into().unwrap()) as usize; + let times_len = u64::from_le_bytes(header[16..24].try_into().unwrap()) as usize; + let diffs_len = u64::from_le_bytes(header[24..32].try_into().unwrap()) as usize; + + // Slice the four columns out of the decompressed buffer, each into + // its own owned `Vec` (`Stash::try_from_bytes` requires owned). + let mut o = 32; + let keys_bytes = decompressed[o..o + keys_len].to_vec(); + o += keys_len; + let vals_bytes = decompressed[o..o + vals_len].to_vec(); + o += vals_len; + let times_bytes = decompressed[o..o + times_len].to_vec(); + o += times_len; + let diffs_bytes = decompressed[o..o + diffs_len].to_vec(); + + let keys = Stash::try_from_bytes(keys_bytes).unwrap(); + let vals = Stash::try_from_bytes(vals_bytes).unwrap(); + let times = Stash::try_from_bytes(times_bytes).unwrap(); + let diffs = Stash::try_from_bytes(diffs_bytes).unwrap(); + let updates: Updates> = Updates { keys, vals, times, diffs }; + Ok(vec![updates.into_typed()]) + } +} + +/// Threshold-based spill policy adapted from timely's +/// `communication::allocator::zero_copy::spill::threshold::Threshold`. +/// +/// Counts records (not bytes) for the threshold check. When the queue's +/// resident records exceed `head_reserve_records + threshold_records`, spill +/// chunks past the head reserve. Unlike TD we don't carve out the last +/// entry — TD's last entry is a `try_merge` target being extended in place; +/// our chunks are all finished, so any of them can be spilled. +pub struct Threshold { + spill: FileSpill, + /// Records near the head of the queue stay resident. + pub head_reserve_records: usize, + /// Spillable surplus: trigger when resident exceeds head + threshold. + pub threshold_records: usize, + /// Counters shared with the caller (chunks_spilled, fires). + pub stats: Arc, +} + +#[derive(Default)] +pub struct ThresholdStats { + pub fires: AtomicUsize, + pub chunks_spilled: AtomicUsize, +} + +impl Threshold { + pub fn new(spill: FileSpill, head_reserve_records: usize, threshold_records: usize) -> Self { + Self { + spill, + head_reserve_records, + threshold_records, + stats: Arc::new(ThresholdStats::default()), + } + } +} + +impl SpillPolicy> for Threshold { + fn apply(&mut self, queue: &mut std::collections::VecDeque>>) { + let resident: usize = queue.iter().map(|e| match e { + Entry::Typed(c) => c.len(), + Entry::Paged(_) => 0, + }).sum(); + if resident <= self.head_reserve_records + self.threshold_records { + return; + } + + // Walk the queue, accumulating a head reserve. Past the reserve, mark + // every Typed entry for spill. + let mut cumulative = 0usize; + let mut target_indices: Vec = Vec::new(); + for (i, entry) in queue.iter().enumerate() { + if let Entry::Typed(c) = entry { + if cumulative >= self.head_reserve_records { + target_indices.push(i); + } + cumulative += c.len(); + } + } + if target_indices.is_empty() { return; } + + // Take the targeted chunks out, leaving empty placeholders we overwrite below. + let mut targets: Vec> = Vec::with_capacity(target_indices.len()); + for &i in &target_indices { + if let Entry::Typed(c) = &mut queue[i] { + targets.push(std::mem::take(c)); + } + } + + let mut handles: Vec>>> = Vec::new(); + self.spill.spill(&mut targets, &mut handles); + // FileSpill drains via pop (LIFO); reverse so handles align with target_indices order. + handles.reverse(); + assert_eq!(target_indices.len(), handles.len()); + self.stats.fires.fetch_add(1, Ordering::Relaxed); + self.stats.chunks_spilled.fetch_add(handles.len(), Ordering::Relaxed); + for (i, handle) in target_indices.into_iter().zip(handles) { + queue[i] = Entry::Paged(handle); + } + } +} + +/// `Batcher` wrapper that installs a `Threshold` policy on a `MergeBatcher` +/// at construction time, reading config from `HEAD` / `THRESH` / `ENABLE_SPILL` +/// statics. Slots into `arrange_core` in place of `ValBatcher` and lets the +/// timely operator drive a spilling merger without surgery to the `Batcher` +/// trait signature. +pub struct SpillBatcher(MergeBatcher<(K, V, T, R)>) +where + (K, V, T, R): Update; + +impl Batcher for SpillBatcher +where + K: columnar::Columnar + 'static, + V: columnar::Columnar + 'static, + T: columnar::Columnar + Timestamp + 'static, + R: columnar::Columnar + 'static, + (K, V, T, R): Update