From b473bd9bb71f807f26c57ae66563bff3caf8e934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Wed, 30 Sep 2026 16:21:14 +0200 Subject: [PATCH 1/4] feat(app): consume the published data app package Pin the starter to @altertable/data-app 0.59.1 and embed only app-owned starter files in npm and native CLI builds. Runtime source, dependencies, and API documentation now belong to the standalone package repository. Migrate checksum-verified legacy apps to registry dependencies, public Bun server exports, explicit styles, and installed package documentation. Validate migrated consumers before removing their runtime and restore both project files and installed dependencies on failure. Preserve unrelated app dependencies and never downgrade a newer installed package. Remove the runtime source watcher and repository runtime checks. Keep app contract and browser boundary checks against the installed public package. Validation: full repository verify gate, 773 CLI tests, 79 black-box tests, 40 desktop/phone browser tests, npm and native smoke checks, and a native migration of a genuine app from the previous starter. --- AGENTS.md | 9 +- COMMANDS.md | 6 +- DEVELOPMENT.md | 2 +- cli-reference.json | 13 +- cli/AGENTS.md | 5 +- cli/package.json | 1 - cli/scripts/check-data-app.ts | 10 +- cli/scripts/package-data-app.ts | 37 +- cli/scripts/smoke-data-app.ts | 17 +- cli/src/commands/app/check.test.ts | 81 ++- cli/src/commands/app/check.ts | 15 +- cli/src/commands/app/create.test.ts | 150 +----- cli/src/commands/app/dev.ts | 101 +--- cli/src/commands/app/lib/distribution.test.ts | 74 +-- cli/src/commands/app/lib/distribution.ts | 44 +- cli/src/commands/app/lib/legacy-app.ts | 176 +++++++ .../app/lib/{runtime.ts => legacy-runtime.ts} | 55 +- cli/src/commands/app/lib/package.test.ts | 53 ++ cli/src/commands/app/lib/package.ts | 175 +++++++ cli/src/commands/app/lib/payload.ts | 2 +- cli/src/commands/app/lib/project-check.js.txt | 13 +- cli/src/commands/app/lib/run.ts | 9 +- cli/src/commands/app/upgrade.test.ts | 416 ++++++++++----- cli/src/commands/app/upgrade.ts | 326 +++++------- data-app/AGENTS.md | 22 +- data-app/README.md | 78 +-- data-app/runtime/.oxfmtrc.json | 4 - data-app/runtime/.oxlintrc.json | 13 - data-app/runtime/README.md | 196 ------- data-app/runtime/bun.lock | 237 --------- data-app/runtime/package.json | 50 -- data-app/runtime/src/assets.d.ts | 1 - data-app/runtime/src/client/index.ts | 80 --- data-app/runtime/src/core/appearance.ts | 267 ---------- data-app/runtime/src/core/config.ts | 9 - data-app/runtime/src/core/contract.ts | 322 ------------ data-app/runtime/src/core/data-view.ts | 65 --- data-app/runtime/src/core/date-range.ts | 115 ---- data-app/runtime/src/core/format.ts | 89 ---- data-app/runtime/src/core/reading.ts | 8 - data-app/runtime/src/core/variables.ts | 240 --------- data-app/runtime/src/react/content.ts | 38 -- data-app/runtime/src/react/hooks.ts | 153 ------ data-app/runtime/src/react/index.ts | 6 - data-app/runtime/src/react/mount.tsx | 36 -- data-app/runtime/src/react/ui/AboutData.css | 117 ----- data-app/runtime/src/react/ui/AboutData.tsx | 323 ------------ .../runtime/src/react/ui/AltertableLogo.tsx | 24 - data-app/runtime/src/react/ui/AppFooter.css | 44 -- data-app/runtime/src/react/ui/AppFooter.tsx | 30 -- data-app/runtime/src/react/ui/AppHeader.css | 67 --- data-app/runtime/src/react/ui/AppHeader.tsx | 48 -- data-app/runtime/src/react/ui/AppLayout.css | 57 -- data-app/runtime/src/react/ui/AppLayout.tsx | 40 -- data-app/runtime/src/react/ui/AppScope.css | 28 - data-app/runtime/src/react/ui/AppScope.tsx | 25 - data-app/runtime/src/react/ui/AppToolbar.css | 24 - data-app/runtime/src/react/ui/AppToolbar.tsx | 120 ----- data-app/runtime/src/react/ui/Breakdown.css | 86 --- data-app/runtime/src/react/ui/Breakdown.tsx | 76 --- data-app/runtime/src/react/ui/Button.css | 60 --- data-app/runtime/src/react/ui/Button.tsx | 26 - data-app/runtime/src/react/ui/Combobox.css | 132 ----- data-app/runtime/src/react/ui/Combobox.tsx | 118 ----- .../runtime/src/react/ui/ComparisonVisual.css | 109 ---- .../runtime/src/react/ui/ComparisonVisual.tsx | 83 --- .../runtime/src/react/ui/ContentSkeleton.css | 53 -- .../runtime/src/react/ui/ContentSkeleton.tsx | 48 -- data-app/runtime/src/react/ui/DataApp.tsx | 157 ------ .../runtime/src/react/ui/DataBoundary.css | 63 --- .../runtime/src/react/ui/DataBoundary.tsx | 70 --- data-app/runtime/src/react/ui/DataPanel.css | 95 ---- data-app/runtime/src/react/ui/DataPanel.tsx | 117 ----- data-app/runtime/src/react/ui/DataSection.tsx | 135 ----- data-app/runtime/src/react/ui/DataTable.css | 69 --- data-app/runtime/src/react/ui/DataTable.tsx | 85 --- .../runtime/src/react/ui/DataViewToast.css | 54 -- .../runtime/src/react/ui/DataViewToast.tsx | 55 -- .../runtime/src/react/ui/DateRangePicker.css | 492 ------------------ .../runtime/src/react/ui/DateRangePicker.tsx | 306 ----------- .../runtime/src/react/ui/DateTimeTooltip.css | 22 - .../runtime/src/react/ui/DateTimeTooltip.tsx | 94 ---- data-app/runtime/src/react/ui/EmptyState.css | 27 - data-app/runtime/src/react/ui/EmptyState.tsx | 24 - .../runtime/src/react/ui/GettingStarted.css | 149 ------ .../runtime/src/react/ui/GettingStarted.tsx | 106 ---- .../src/react/ui/GlossaryDefinition.css | 32 -- .../src/react/ui/GlossaryDefinition.tsx | 24 - .../src/react/ui/GlossaryExplanation.tsx | 51 -- data-app/runtime/src/react/ui/Grid.css | 116 ----- data-app/runtime/src/react/ui/Grid.tsx | 37 -- data-app/runtime/src/react/ui/GridItem.tsx | 16 - data-app/runtime/src/react/ui/HelpPopover.css | 26 - data-app/runtime/src/react/ui/HelpPopover.tsx | 123 ----- data-app/runtime/src/react/ui/IconButton.tsx | 63 --- data-app/runtime/src/react/ui/Inspect.css | 48 -- .../src/react/ui/InspectionContext.tsx | 16 - data-app/runtime/src/react/ui/Kbd.css | 14 - data-app/runtime/src/react/ui/Kbd.tsx | 15 - data-app/runtime/src/react/ui/LiveControl.css | 102 ---- data-app/runtime/src/react/ui/LiveControl.tsx | 81 --- .../runtime/src/react/ui/MetricWidget.css | 81 --- .../runtime/src/react/ui/MetricWidget.tsx | 140 ----- .../runtime/src/react/ui/PeriodSummary.css | 25 - .../runtime/src/react/ui/PeriodSummary.tsx | 68 --- data-app/runtime/src/react/ui/PlayStory.css | 268 ---------- data-app/runtime/src/react/ui/PlayStory.tsx | 315 ----------- data-app/runtime/src/react/ui/QueryList.css | 154 ------ data-app/runtime/src/react/ui/QueryList.tsx | 170 ------ data-app/runtime/src/react/ui/Ranking.css | 51 -- data-app/runtime/src/react/ui/Ranking.tsx | 33 -- .../runtime/src/react/ui/RefreshControl.css | 50 -- .../runtime/src/react/ui/RefreshControl.tsx | 50 -- .../runtime/src/react/ui/RefreshRegion.css | 13 - .../runtime/src/react/ui/RefreshRegion.tsx | 33 -- data-app/runtime/src/react/ui/SearchField.css | 104 ---- data-app/runtime/src/react/ui/SearchField.tsx | 125 ----- data-app/runtime/src/react/ui/SearchMatch.css | 7 - data-app/runtime/src/react/ui/SearchMatch.tsx | 27 - data-app/runtime/src/react/ui/Sheet.css | 192 ------- data-app/runtime/src/react/ui/Sheet.tsx | 144 ----- data-app/runtime/src/react/ui/Skeleton.css | 24 - data-app/runtime/src/react/ui/Skeleton.tsx | 13 - data-app/runtime/src/react/ui/Stack.css | 14 - data-app/runtime/src/react/ui/Stack.tsx | 17 - data-app/runtime/src/react/ui/StatusPanel.css | 54 -- data-app/runtime/src/react/ui/StatusPanel.tsx | 43 -- .../runtime/src/react/ui/StorySection.css | 46 -- .../runtime/src/react/ui/StorySection.tsx | 37 -- data-app/runtime/src/react/ui/TableWidget.css | 50 -- data-app/runtime/src/react/ui/TableWidget.tsx | 202 ------- data-app/runtime/src/react/ui/Tabs.css | 55 -- data-app/runtime/src/react/ui/Tabs.tsx | 35 -- .../runtime/src/react/ui/ThemeSelector.css | 51 -- .../runtime/src/react/ui/ThemeSelector.tsx | 96 ---- data-app/runtime/src/react/ui/Tooltip.css | 47 -- data-app/runtime/src/react/ui/Tooltip.tsx | 237 --------- data-app/runtime/src/react/ui/UpdatedAt.css | 15 - data-app/runtime/src/react/ui/UpdatedAt.tsx | 47 -- data-app/runtime/src/react/ui/VariableBar.css | 11 - data-app/runtime/src/react/ui/VariableBar.tsx | 27 - .../src/react/ui/VisualizationWidget.css | 7 - .../src/react/ui/VisualizationWidget.tsx | 138 ----- .../runtime/src/react/ui/WidgetDisclosure.css | 42 -- .../runtime/src/react/ui/WidgetDisclosure.tsx | 21 - .../runtime/src/react/ui/WidgetEvidence.ts | 12 - .../runtime/src/react/ui/WidgetViewTabs.css | 26 - .../runtime/src/react/ui/WidgetViewTabs.tsx | 53 -- data-app/runtime/src/react/ui/chartColor.ts | 5 - data-app/runtime/src/react/ui/classNames.ts | 3 - data-app/runtime/src/react/ui/comparison.ts | 51 -- data-app/runtime/src/react/ui/data-context.ts | 127 ----- .../runtime/src/react/ui/data-identifiers.css | 20 - .../runtime/src/react/ui/data-identifiers.tsx | 91 ---- data-app/runtime/src/react/ui/icons.ts | 88 ---- data-app/runtime/src/react/ui/index.ts | 164 ------ data-app/runtime/src/react/ui/metric.ts | 22 - data-app/runtime/src/react/ui/search.ts | 42 -- data-app/runtime/src/react/ui/searchItems.ts | 123 ----- data-app/runtime/src/react/ui/shortcuts.ts | 72 --- data-app/runtime/src/react/ui/variables.ts | 143 ----- data-app/runtime/src/react/view-controls.tsx | 48 -- data-app/runtime/src/react/view.ts | 75 --- data-app/runtime/src/server/handler.ts | 175 ------- data-app/runtime/src/server/index.ts | 3 - data-app/runtime/src/server/local.ts | 124 ----- data-app/runtime/tests/authoring.test.ts | 168 ------ data-app/runtime/tests/bindings.test.tsx | 247 --------- data-app/runtime/tests/boundaries.test.ts | 52 -- data-app/runtime/tests/components.test.tsx | 294 ----------- data-app/runtime/tests/composition.tsx | 206 -------- .../runtime/tests/data-identifiers.test.tsx | 64 --- data-app/runtime/tests/format.test.ts | 22 - data-app/runtime/tests/harness.test.tsx | 175 ------- data-app/runtime/tests/server.test.ts | 204 -------- data-app/runtime/tests/view-composition.tsx | 99 ---- data-app/runtime/tsconfig.json | 20 - data-app/starter/AGENTS.md | 12 +- data-app/starter/README.md | 7 +- data-app/starter/bun.lock | 4 +- data-app/starter/docs/data.md | 2 +- data-app/starter/docs/views.md | 2 +- data-app/starter/fixtures/main.tsx | 76 +-- data-app/starter/fixtures/view.tsx | 1 + data-app/starter/package.json | 2 +- data-app/starter/src/main.tsx | 1 + data-app/starter/src/server.ts | 2 +- data-app/tests/bun.lock | 57 ++ data-app/tests/package.json | 7 +- data-app/tests/server.ts | 4 +- data-app/tests/starter.spec.ts | 31 +- 191 files changed, 1242 insertions(+), 13804 deletions(-) create mode 100644 cli/src/commands/app/lib/legacy-app.ts rename cli/src/commands/app/lib/{runtime.ts => legacy-runtime.ts} (50%) create mode 100644 cli/src/commands/app/lib/package.test.ts create mode 100644 cli/src/commands/app/lib/package.ts delete mode 100644 data-app/runtime/.oxfmtrc.json delete mode 100644 data-app/runtime/.oxlintrc.json delete mode 100644 data-app/runtime/README.md delete mode 100644 data-app/runtime/bun.lock delete mode 100644 data-app/runtime/package.json delete mode 100644 data-app/runtime/src/assets.d.ts delete mode 100644 data-app/runtime/src/client/index.ts delete mode 100644 data-app/runtime/src/core/appearance.ts delete mode 100644 data-app/runtime/src/core/config.ts delete mode 100644 data-app/runtime/src/core/contract.ts delete mode 100644 data-app/runtime/src/core/data-view.ts delete mode 100644 data-app/runtime/src/core/date-range.ts delete mode 100644 data-app/runtime/src/core/format.ts delete mode 100644 data-app/runtime/src/core/reading.ts delete mode 100644 data-app/runtime/src/core/variables.ts delete mode 100644 data-app/runtime/src/react/content.ts delete mode 100644 data-app/runtime/src/react/hooks.ts delete mode 100644 data-app/runtime/src/react/index.ts delete mode 100644 data-app/runtime/src/react/mount.tsx delete mode 100644 data-app/runtime/src/react/ui/AboutData.css delete mode 100644 data-app/runtime/src/react/ui/AboutData.tsx delete mode 100644 data-app/runtime/src/react/ui/AltertableLogo.tsx delete mode 100644 data-app/runtime/src/react/ui/AppFooter.css delete mode 100644 data-app/runtime/src/react/ui/AppFooter.tsx delete mode 100644 data-app/runtime/src/react/ui/AppHeader.css delete mode 100644 data-app/runtime/src/react/ui/AppHeader.tsx delete mode 100644 data-app/runtime/src/react/ui/AppLayout.css delete mode 100644 data-app/runtime/src/react/ui/AppLayout.tsx delete mode 100644 data-app/runtime/src/react/ui/AppScope.css delete mode 100644 data-app/runtime/src/react/ui/AppScope.tsx delete mode 100644 data-app/runtime/src/react/ui/AppToolbar.css delete mode 100644 data-app/runtime/src/react/ui/AppToolbar.tsx delete mode 100644 data-app/runtime/src/react/ui/Breakdown.css delete mode 100644 data-app/runtime/src/react/ui/Breakdown.tsx delete mode 100644 data-app/runtime/src/react/ui/Button.css delete mode 100644 data-app/runtime/src/react/ui/Button.tsx delete mode 100644 data-app/runtime/src/react/ui/Combobox.css delete mode 100644 data-app/runtime/src/react/ui/Combobox.tsx delete mode 100644 data-app/runtime/src/react/ui/ComparisonVisual.css delete mode 100644 data-app/runtime/src/react/ui/ComparisonVisual.tsx delete mode 100644 data-app/runtime/src/react/ui/ContentSkeleton.css delete mode 100644 data-app/runtime/src/react/ui/ContentSkeleton.tsx delete mode 100644 data-app/runtime/src/react/ui/DataApp.tsx delete mode 100644 data-app/runtime/src/react/ui/DataBoundary.css delete mode 100644 data-app/runtime/src/react/ui/DataBoundary.tsx delete mode 100644 data-app/runtime/src/react/ui/DataPanel.css delete mode 100644 data-app/runtime/src/react/ui/DataPanel.tsx delete mode 100644 data-app/runtime/src/react/ui/DataSection.tsx delete mode 100644 data-app/runtime/src/react/ui/DataTable.css delete mode 100644 data-app/runtime/src/react/ui/DataTable.tsx delete mode 100644 data-app/runtime/src/react/ui/DataViewToast.css delete mode 100644 data-app/runtime/src/react/ui/DataViewToast.tsx delete mode 100644 data-app/runtime/src/react/ui/DateRangePicker.css delete mode 100644 data-app/runtime/src/react/ui/DateRangePicker.tsx delete mode 100644 data-app/runtime/src/react/ui/DateTimeTooltip.css delete mode 100644 data-app/runtime/src/react/ui/DateTimeTooltip.tsx delete mode 100644 data-app/runtime/src/react/ui/EmptyState.css delete mode 100644 data-app/runtime/src/react/ui/EmptyState.tsx delete mode 100644 data-app/runtime/src/react/ui/GettingStarted.css delete mode 100644 data-app/runtime/src/react/ui/GettingStarted.tsx delete mode 100644 data-app/runtime/src/react/ui/GlossaryDefinition.css delete mode 100644 data-app/runtime/src/react/ui/GlossaryDefinition.tsx delete mode 100644 data-app/runtime/src/react/ui/GlossaryExplanation.tsx delete mode 100644 data-app/runtime/src/react/ui/Grid.css delete mode 100644 data-app/runtime/src/react/ui/Grid.tsx delete mode 100644 data-app/runtime/src/react/ui/GridItem.tsx delete mode 100644 data-app/runtime/src/react/ui/HelpPopover.css delete mode 100644 data-app/runtime/src/react/ui/HelpPopover.tsx delete mode 100644 data-app/runtime/src/react/ui/IconButton.tsx delete mode 100644 data-app/runtime/src/react/ui/Inspect.css delete mode 100644 data-app/runtime/src/react/ui/InspectionContext.tsx delete mode 100644 data-app/runtime/src/react/ui/Kbd.css delete mode 100644 data-app/runtime/src/react/ui/Kbd.tsx delete mode 100644 data-app/runtime/src/react/ui/LiveControl.css delete mode 100644 data-app/runtime/src/react/ui/LiveControl.tsx delete mode 100644 data-app/runtime/src/react/ui/MetricWidget.css delete mode 100644 data-app/runtime/src/react/ui/MetricWidget.tsx delete mode 100644 data-app/runtime/src/react/ui/PeriodSummary.css delete mode 100644 data-app/runtime/src/react/ui/PeriodSummary.tsx delete mode 100644 data-app/runtime/src/react/ui/PlayStory.css delete mode 100644 data-app/runtime/src/react/ui/PlayStory.tsx delete mode 100644 data-app/runtime/src/react/ui/QueryList.css delete mode 100644 data-app/runtime/src/react/ui/QueryList.tsx delete mode 100644 data-app/runtime/src/react/ui/Ranking.css delete mode 100644 data-app/runtime/src/react/ui/Ranking.tsx delete mode 100644 data-app/runtime/src/react/ui/RefreshControl.css delete mode 100644 data-app/runtime/src/react/ui/RefreshControl.tsx delete mode 100644 data-app/runtime/src/react/ui/RefreshRegion.css delete mode 100644 data-app/runtime/src/react/ui/RefreshRegion.tsx delete mode 100644 data-app/runtime/src/react/ui/SearchField.css delete mode 100644 data-app/runtime/src/react/ui/SearchField.tsx delete mode 100644 data-app/runtime/src/react/ui/SearchMatch.css delete mode 100644 data-app/runtime/src/react/ui/SearchMatch.tsx delete mode 100644 data-app/runtime/src/react/ui/Sheet.css delete mode 100644 data-app/runtime/src/react/ui/Sheet.tsx delete mode 100644 data-app/runtime/src/react/ui/Skeleton.css delete mode 100644 data-app/runtime/src/react/ui/Skeleton.tsx delete mode 100644 data-app/runtime/src/react/ui/Stack.css delete mode 100644 data-app/runtime/src/react/ui/Stack.tsx delete mode 100644 data-app/runtime/src/react/ui/StatusPanel.css delete mode 100644 data-app/runtime/src/react/ui/StatusPanel.tsx delete mode 100644 data-app/runtime/src/react/ui/StorySection.css delete mode 100644 data-app/runtime/src/react/ui/StorySection.tsx delete mode 100644 data-app/runtime/src/react/ui/TableWidget.css delete mode 100644 data-app/runtime/src/react/ui/TableWidget.tsx delete mode 100644 data-app/runtime/src/react/ui/Tabs.css delete mode 100644 data-app/runtime/src/react/ui/Tabs.tsx delete mode 100644 data-app/runtime/src/react/ui/ThemeSelector.css delete mode 100644 data-app/runtime/src/react/ui/ThemeSelector.tsx delete mode 100644 data-app/runtime/src/react/ui/Tooltip.css delete mode 100644 data-app/runtime/src/react/ui/Tooltip.tsx delete mode 100644 data-app/runtime/src/react/ui/UpdatedAt.css delete mode 100644 data-app/runtime/src/react/ui/UpdatedAt.tsx delete mode 100644 data-app/runtime/src/react/ui/VariableBar.css delete mode 100644 data-app/runtime/src/react/ui/VariableBar.tsx delete mode 100644 data-app/runtime/src/react/ui/VisualizationWidget.css delete mode 100644 data-app/runtime/src/react/ui/VisualizationWidget.tsx delete mode 100644 data-app/runtime/src/react/ui/WidgetDisclosure.css delete mode 100644 data-app/runtime/src/react/ui/WidgetDisclosure.tsx delete mode 100644 data-app/runtime/src/react/ui/WidgetEvidence.ts delete mode 100644 data-app/runtime/src/react/ui/WidgetViewTabs.css delete mode 100644 data-app/runtime/src/react/ui/WidgetViewTabs.tsx delete mode 100644 data-app/runtime/src/react/ui/chartColor.ts delete mode 100644 data-app/runtime/src/react/ui/classNames.ts delete mode 100644 data-app/runtime/src/react/ui/comparison.ts delete mode 100644 data-app/runtime/src/react/ui/data-context.ts delete mode 100644 data-app/runtime/src/react/ui/data-identifiers.css delete mode 100644 data-app/runtime/src/react/ui/data-identifiers.tsx delete mode 100644 data-app/runtime/src/react/ui/icons.ts delete mode 100644 data-app/runtime/src/react/ui/index.ts delete mode 100644 data-app/runtime/src/react/ui/metric.ts delete mode 100644 data-app/runtime/src/react/ui/search.ts delete mode 100644 data-app/runtime/src/react/ui/searchItems.ts delete mode 100644 data-app/runtime/src/react/ui/shortcuts.ts delete mode 100644 data-app/runtime/src/react/ui/variables.ts delete mode 100644 data-app/runtime/src/react/view-controls.tsx delete mode 100644 data-app/runtime/src/react/view.ts delete mode 100644 data-app/runtime/src/server/handler.ts delete mode 100644 data-app/runtime/src/server/index.ts delete mode 100644 data-app/runtime/src/server/local.ts delete mode 100644 data-app/runtime/tests/authoring.test.ts delete mode 100644 data-app/runtime/tests/bindings.test.tsx delete mode 100644 data-app/runtime/tests/boundaries.test.ts delete mode 100644 data-app/runtime/tests/components.test.tsx delete mode 100644 data-app/runtime/tests/composition.tsx delete mode 100644 data-app/runtime/tests/data-identifiers.test.tsx delete mode 100644 data-app/runtime/tests/format.test.ts delete mode 100644 data-app/runtime/tests/harness.test.tsx delete mode 100644 data-app/runtime/tests/server.test.ts delete mode 100644 data-app/runtime/tests/view-composition.tsx delete mode 100644 data-app/runtime/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 52336be6..237fd7f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,14 +12,13 @@ Altertable CLI — a TypeScript/Bun command-line tool for querying and managing | ---------------------- | --------------------------------------------------------- | | `cli/src/` | CLI commands, HTTP clients, formatting, config | | `cli/src/**/*.test.ts` | Colocated Bun unit tests for CLI logic | -| `data-app/runtime/` | Shared data app package, contracts, components, unit tests | | `data-app/starter/` | Runnable getting-started app copied by `app create` | -| `data-app/tests/` | Browser tests and fixtures for the starter and runtime | +| `data-app/tests/` | Browser tests and fixtures consuming the published package | | `tests/` | Black-box end-user CLI tests run through `bin/altertable` | | `specs/` | Client API specs (submodule — read-only from this repo) | | `bin/altertable` | Dev launcher — do not edit | -For data app runtime, starter, distribution, and browser work, follow [data-app/AGENTS.md](data-app/AGENTS.md). +For data app starter, distribution, and browser work, follow [data-app/AGENTS.md](data-app/AGENTS.md). ## Start here @@ -33,13 +32,11 @@ git submodule update --init --recursive # first checkout only For data app changes, run the focused checks first from the repository root: ```fish -bun run --cwd data-app/runtime typecheck -bun run --cwd data-app/runtime test bun run --cwd data-app/starter build bun run --cwd cli data-app:test:browser ``` -The starter build requires its local runtime; see [data-app/README.md](data-app/README.md) +The starter build requires its published package dependencies; see [data-app/README.md](data-app/README.md) for setup and browser prerequisites. Run `bun run --cwd cli data-app:check` to check all three data app projects together. Use `./scripts/verify.sh --quick` after focused checks, then default `./scripts/verify.sh` before opening a PR. diff --git a/COMMANDS.md b/COMMANDS.md index df07c000..cb14da0f 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -425,7 +425,7 @@ altertable app create|dev|build|check|upgrade - `dev` — Preview a data app locally with lakehouse access. - `build` — Typecheck and build a data app without Altertable credentials. - `check` — Validate a data app's format, lint, types, contracts, build, and client credential boundary. -- `upgrade` — Update an unmodified data app runtime to the CLI's current version. +- `upgrade` — Update the data app package to the CLI's tested version, migrating vendored apps. **Examples** @@ -484,14 +484,12 @@ altertable app dev [options] | --- | --- | | `--dir ` | App directory (default: current directory). | | `--port ` | Local dev server port (1–65535; default: app setting). | -| `--watch-runtime` | Upgrade generated runtime on source changes and restart preview. | **Examples** ```bash altertable app dev altertable app dev --port 3022 -altertable app dev --watch-runtime altertable --profile staging app dev --dir ./my-app ``` @@ -544,7 +542,7 @@ altertable app check --lakehouse #### `altertable app upgrade` -Update an unmodified data app runtime to the CLI's current version. +Update the data app package to the CLI's tested version, migrating vendored apps. **Usage** diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ba2be584..626f5ada 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -231,4 +231,4 @@ When bumping the `specs/` submodule, extend the mapped tests before merge. ## Data apps -The runnable starter and shared runtime live in [`data-app/`](data-app/README.md). That guide covers source ownership, the copy allowlist, CLI embedding, runtime upgrades, and browser tests. +The runnable starter and package-consumer browser tests live in [`data-app/`](data-app/README.md). That guide covers starter embedding, npm package pins, legacy migration, and verification. Runtime development and releases belong to [the Data App repository](https://github.com/altertable-ai/data-app). diff --git a/cli-reference.json b/cli-reference.json index 743b3a05..d00e1df9 100644 --- a/cli-reference.json +++ b/cli-reference.json @@ -897,22 +897,11 @@ "repeatable": false, "scope": "command", "values": [] - }, - { - "name": "watch-runtime", - "aliases": [], - "type": "boolean", - "description": "Upgrade generated runtime on source changes and restart preview.", - "required": false, - "repeatable": false, - "scope": "command", - "values": [] } ], "examples": [ "altertable app dev", "altertable app dev --port 3022", - "altertable app dev --watch-runtime", "altertable --profile staging app dev --dir ./my-app" ], "subcommands": [] @@ -984,7 +973,7 @@ { "id": "altertable-app-upgrade", "command": "altertable app upgrade", - "description": "Update an unmodified data app runtime to the CLI's current version.", + "description": "Update the data app package to the CLI's tested version, migrating vendored apps.", "usage": [ "altertable app upgrade [options]" ], diff --git a/cli/AGENTS.md b/cli/AGENTS.md index 1832689f..68b85b7e 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -51,9 +51,8 @@ bun test "$PWD"/tests/integration.e2e.ts ## Conventions -- For generated app authoring, edit [the starter app guide](../data-app/starter/AGENTS.md). Keep it a task router into focused authoring guides and the runtime API map. Types describe props; reserve JSDoc for constraints, ownership, security boundaries, and surprising behavior. -- When changing a runtime primitive, preserve semantic ownership: layout spacing belongs to `AppLayout`, `Stack`, and `TabPanels`; filters and date bounds belong to their variable and control contracts; request states belong to `DataSection`. Add a prop or slot when app authors repeatedly need the same custom wrapper. -- Add recurring icons to `../data-app/runtime/src/react/ui/icons.ts` with a semantic name and fixed optical size. Keep toolbar and presentation button variants explicit in their owning components. Verify the generated app in both themes and at desktop and phone widths. +- For generated app authoring, edit [the starter app guide](../data-app/starter/AGENTS.md). Keep it a task router into focused authoring guides and the installed package docs. Types describe props; reserve JSDoc for constraints, ownership, security boundaries, and surprising behavior. +- Runtime contracts and UI components are maintained in [the Data App repository](https://github.com/altertable-ai/data-app). This repository owns the starter and its integration with CLI commands. - Declare and export each command immediately after its imports; keep supporting helpers and types below it. - Import command types and `defineArgs` from `src/lib/command.ts`; its metadata drives parsing, help, completion, and generated documentation. diff --git a/cli/package.json b/cli/package.json index b0ad54e6..4a2c9372 100644 --- a/cli/package.json +++ b/cli/package.json @@ -44,7 +44,6 @@ "pack:check": "bun run build && bun pm pack --dry-run", "knip": "knip --no-config-hints", "knip:production": "knip --production --no-config-hints", - "data-app:setup": "bun run scripts/package-data-app.ts --setup", "data-app:check": "bun run scripts/check-data-app.ts", "data-app:test:browser": "cd ../data-app/tests && bun run test" }, diff --git a/cli/scripts/check-data-app.ts b/cli/scripts/check-data-app.ts index fe6f3b14..e51aa073 100644 --- a/cli/scripts/check-data-app.ts +++ b/cli/scripts/check-data-app.ts @@ -1,17 +1,11 @@ import { join } from "node:path"; import { dataAppDirectory } from "@/commands/app/lib/distribution.ts"; -import { setupDataApp } from "@/../scripts/package-data-app.ts"; -await setupDataApp(); -for (const project of ["runtime", "starter", "tests"]) { +for (const project of ["starter", "tests"]) { const commands = [ ["install", "--frozen-lockfile"], ...["typecheck", "lint", "format:check"].map((name) => ["run", name]), - ...(project === "runtime" - ? [["test", "tests"]] - : project === "starter" - ? [["run", "build"]] - : []), + ...(project === "starter" ? [["run", "build"]] : []), ]; for (const args of commands) { const child = Bun.spawn([process.execPath, ...args], { diff --git a/cli/scripts/package-data-app.ts b/cli/scripts/package-data-app.ts index c7b102e8..ad5e221b 100644 --- a/cli/scripts/package-data-app.ts +++ b/cli/scripts/package-data-app.ts @@ -1,18 +1,10 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; import type { BunPlugin } from "bun"; -import { upgradeApp } from "@/commands/app/upgrade.ts"; -import { - dataAppDirectory, - readDataAppPayload, - runtimeIntegrity, - runtimePath, -} from "@/commands/app/lib/distribution.ts"; +import { readDataAppPayload } from "@/commands/app/lib/distribution.ts"; -/** Both npm and native releases embed the same deterministic source payload. */ +/** Both npm and native releases embed the same deterministic starter template. */ export function dataAppPlugin(): BunPlugin { return { - name: "data-app-payload", + name: "data-app-starter", setup(build) { build.onLoad({ filter: /[/\\]commands[/\\]app[/\\]lib[/\\]payload\.ts$/ }, async () => ({ contents: `export const dataAppPayload = ${JSON.stringify(await readDataAppPayload())};`, @@ -21,26 +13,3 @@ export function dataAppPlugin(): BunPlugin { }, }; } - -export async function setupDataApp(): Promise { - const payload = await readDataAppPayload(); - const directory = join(dataAppDirectory, "starter", runtimePath); - if (await Bun.file(join(directory, "integrity.json")).exists()) { - await upgradeApp(join(dataAppDirectory, "starter"), { runtimeFiles: payload.runtime }); - return; - } - for (const [name, content] of Object.entries(payload.runtime)) { - await mkdir(dirname(join(directory, name)), { recursive: true }); - await writeFile(join(directory, name), content); - } - await writeFile( - join(directory, "integrity.json"), - `${JSON.stringify(runtimeIntegrity(payload.runtime), null, 2)}\n`, - ); -} - -if (import.meta.main) { - if (!Bun.argv.includes("--setup")) - throw new Error("Usage: bun run scripts/package-data-app.ts --setup"); - await setupDataApp(); -} diff --git a/cli/scripts/smoke-data-app.ts b/cli/scripts/smoke-data-app.ts index fc998aef..bec4b383 100644 --- a/cli/scripts/smoke-data-app.ts +++ b/cli/scripts/smoke-data-app.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -/** Exercise the shipped CLI outside the checkout, including its embedded app sources. */ +/** Exercise the shipped CLI outside the checkout, including its embedded starter and published runtime. */ export async function smokeDataApp(command: string[], scaffoldOnly = false): Promise { const directory = await mkdtemp(join(tmpdir(), "altertable-packaged-app-")); const app = join(directory, "app"); @@ -24,10 +24,21 @@ export async function smokeDataApp(command: string[], scaffoldOnly = false): Pro [...command, "app", "create", "package-smoke", "--dir", app, "--without-profile"], directory, ); - if (!(await Bun.file(join(app, ".altertable/runtime/src/server/index.ts")).exists())) - throw new Error("Packaged runtime is missing"); + const manifest = await Bun.file(join(app, "package.json")).json(); + if (!/^\d+\.\d+\.\d+$/.test(manifest.dependencies?.["@altertable/data-app"] ?? "")) + throw new Error("Packaged starter must pin a published runtime"); + if (await Bun.file(join(app, ".altertable/runtime/package.json")).exists()) + throw new Error("Packaged starter must not vendor the runtime"); if (scaffoldOnly) return; await run([process.execPath, "install", "--frozen-lockfile", "--ignore-scripts"], app); + await run( + [ + process.execPath, + "-e", + 'import { createDataHandler } from "@altertable/data-app/server"; import { localLakehouse } from "@altertable/data-app/server/bun"; if (typeof createDataHandler !== "function" || typeof localLakehouse !== "function") process.exit(1);', + ], + app, + ); await run([...command, "app", "check", "--dir", app], directory); } finally { await rm(directory, { recursive: true, force: true }); diff --git a/cli/src/commands/app/check.test.ts b/cli/src/commands/app/check.test.ts index 8ba6454a..92dca45a 100644 --- a/cli/src/commands/app/check.test.ts +++ b/cli/src/commands/app/check.test.ts @@ -8,6 +8,20 @@ let directory: string; beforeEach(() => { directory = mkdtempSync(join(tmpdir(), "altertable-app-check-")); + mkdirSync(join(directory, "node_modules/@altertable/data-app/dist/core"), { recursive: true }); + mkdirSync(join(directory, "node_modules/@altertable/data-app/dist/server"), { recursive: true }); + writeFileSync( + join(directory, "node_modules/@altertable/data-app/package.json"), + JSON.stringify({ + name: "@altertable/data-app", + type: "module", + exports: { + "./appearance": "./dist/core/appearance.js", + "./server": "./dist/server/index.js", + "./server/bun": "./dist/server/local.js", + }, + }), + ); }); afterEach(() => { rmSync(directory, { recursive: true, force: true }); @@ -16,8 +30,10 @@ afterEach(() => { describe("data app contract", () => { test("rejects unbounded or unchecked operations in the app process", async () => { mkdirSync(join(directory, "src")); - mkdirSync(join(directory, ".altertable/runtime/src/core"), { recursive: true }); - mkdirSync(join(directory, ".altertable/runtime/src/server"), { recursive: true }); + mkdirSync(join(directory, "node_modules/@altertable/data-app/dist/core"), { recursive: true }); + mkdirSync(join(directory, "node_modules/@altertable/data-app/dist/server"), { + recursive: true, + }); writeFileSync( join(directory, "app.json"), JSON.stringify({ @@ -26,7 +42,7 @@ describe("data app contract", () => { }), ); writeFileSync( - join(directory, ".altertable/runtime/src/core/appearance.ts"), + join(directory, "node_modules/@altertable/data-app/dist/core/appearance.js"), "export function parseAppearance() {}", ); writeFileSync( @@ -85,11 +101,11 @@ describe("data app contract", () => { test("browser bundles reject value imports of operations but allow type imports", async () => { mkdirSync(join(directory, "src")); - mkdirSync(join(directory, ".altertable/runtime/src/core"), { recursive: true }); - mkdirSync(join(directory, ".altertable/runtime/src/server"), { recursive: true }); + mkdirSync(join(directory, "node_modules/@altertable/data-app/dist/core"), { recursive: true }); + mkdirSync(join(directory, "node_modules/@altertable/data-app/dist/server"), { recursive: true }); writeFileSync(join(directory, "app.json"), JSON.stringify({ schemaVersion: 1, title: "Test" })); writeFileSync( - join(directory, ".altertable/runtime/src/core/appearance.ts"), + join(directory, "node_modules/@altertable/data-app/dist/core/appearance.js"), "export function parseAppearance() {}", ); writeFileSync( @@ -113,24 +129,24 @@ test("browser bundles reject value imports of operations but allow type imports" await checkAppProject(directory); }); -test("lakehouse checks execute every declared input through the current runtime layout", async () => { +test("lakehouse checks execute every declared input through public package exports", async () => { mkdirSync(join(directory, "src")); - mkdirSync(join(directory, ".altertable/runtime/src/core"), { recursive: true }); - mkdirSync(join(directory, ".altertable/runtime/src/server"), { recursive: true }); + mkdirSync(join(directory, "node_modules/@altertable/data-app/dist/core"), { recursive: true }); + mkdirSync(join(directory, "node_modules/@altertable/data-app/dist/server"), { recursive: true }); writeFileSync( join(directory, "app.json"), JSON.stringify({ schemaVersion: 1, title: "Live check" }), ); writeFileSync( - join(directory, ".altertable/runtime/src/core/appearance.ts"), + join(directory, "node_modules/@altertable/data-app/dist/core/appearance.js"), "export function parseAppearance() {}", ); writeFileSync( - join(directory, ".altertable/runtime/src/server/local.ts"), + join(directory, "node_modules/@altertable/data-app/dist/server/local.js"), "export function localLakehouse() { return {}; }", ); writeFileSync( - join(directory, ".altertable/runtime/src/server/index.ts"), + join(directory, "node_modules/@altertable/data-app/dist/server/index.js"), ` export function createDataHandler(operations) { return async (request) => { @@ -157,3 +173,44 @@ test("lakehouse checks execute every declared input through the current runtime await checkAppProject(directory, {}); expect(readFileSync(join(directory, "executed.txt"), "utf8")).toBe("12"); }); + +test.each(["@altertable/data-app/server", "@altertable/data-app/server/bun"])( + "browser bundles reject the published %s entry even without credential strings", + async (entry) => { + mkdirSync(join(directory, "src")); + writeFileSync( + join(directory, "app.json"), + JSON.stringify({ schemaVersion: 1, title: "Boundary" }), + ); + writeFileSync( + join(directory, "node_modules/@altertable/data-app/dist/core/appearance.js"), + "export function parseAppearance() {}", + ); + writeFileSync( + join(directory, "node_modules/@altertable/data-app/dist/server/index.js"), + "export const secretFreeHandler = 1;", + ); + writeFileSync( + join(directory, "node_modules/@altertable/data-app/dist/server/local.js"), + "export const secretFreeHandler = 1;", + ); + writeFileSync( + join(directory, "src/operations.ts"), + "export const operations = { test: { checks: [{}], input: v => v, output: v => v, run: async () => ({}), policy: { maxQueryRows: 1, maxDurationMs: 1000 } } };", + ); + writeFileSync( + join(directory, "src/index.html"), + '', + ); + writeFileSync( + join(directory, "src/main.ts"), + `import { secretFreeHandler } from "${entry}"; console.log(secretFreeHandler);`, + ); + expect(checkAppProject(directory)).rejects.toThrow("Data app validation failed."); + writeFileSync( + join(directory, "src/main.ts"), + `import type { secretFreeHandler } from "${entry}"; console.log("browser safe");`, + ); + await checkAppProject(directory); + }, +); diff --git a/cli/src/commands/app/check.ts b/cli/src/commands/app/check.ts index 0cfe6819..863a4e7e 100644 --- a/cli/src/commands/app/check.ts +++ b/cli/src/commands/app/check.ts @@ -13,7 +13,7 @@ import { requireAppScripts, runAppCommand, } from "@/commands/app/lib/run.ts"; -import { currentRuntimeIntegrity, installedRuntimeIntegrity } from "@/commands/app/lib/runtime.ts"; +import { requirePublishedDataApp } from "@/commands/app/lib/package.ts"; import projectCheckScript from "@/commands/app/lib/project-check.js.txt"; type AppManifest = { @@ -42,17 +42,8 @@ export const appCheckCommand = defineCommand({ requireAppScripts(directory, ["format:check", "lint", "typecheck", "build"]); const manifest = await readManifest(directory); if (args.lakehouse) await checkAppScope(directory, execution.profile); - const installed = await installedRuntimeIntegrity(directory); - const current = currentRuntimeIntegrity(); - if ( - installed.version !== current.version || - Object.entries(current.sha256).some(([name, checksum]) => installed.sha256[name] !== checksum) - ) { - throw new ConfigurationError( - `Data app runtime ${installed.version} is outdated. Run \`altertable app upgrade\`.`, - ); - } - if ((await runAppCommand("install", directory)) !== 0) return { exitCode: EXIT_GENERIC }; + await requirePublishedDataApp(directory); + if ((await runAppCommand("install-frozen", directory)) !== 0) return { exitCode: EXIT_GENERIC }; if ((await runAppCommand("format:check", directory)) !== 0) return { exitCode: EXIT_GENERIC }; if ((await runAppCommand("lint", directory)) !== 0) return { exitCode: EXIT_GENERIC }; if ((await runAppCommand("typecheck", directory)) !== 0) return { exitCode: EXIT_GENERIC }; diff --git a/cli/src/commands/app/create.test.ts b/cli/src/commands/app/create.test.ts index 6e105e99..e201b11e 100644 --- a/cli/src/commands/app/create.test.ts +++ b/cli/src/commands/app/create.test.ts @@ -3,12 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no import { tmpdir } from "node:os"; import { join } from "node:path"; import { runCommandWithTestRuntime } from "@/test-utils/cli.ts"; -import { upgradeApp } from "@/commands/app/upgrade.ts"; -import { - currentRuntimeIntegrity, - installedRuntimeIntegrity, - readRuntimeSource, -} from "@/commands/app/lib/runtime.ts"; +import { recommendedDataAppVersion } from "@/commands/app/lib/package.ts"; import { configSet, ensureProfileExists, setActiveProfile } from "@/lib/profile-store.ts"; let home: string; @@ -32,7 +27,7 @@ afterEach(() => { }); describe("app create", () => { - test("creates a self-contained offline project when explicitly requested", async () => { + test("scaffolds a pinned package project without registry access", async () => { const directory = join(home, "product-pulse"); const result = await runCommandWithTestRuntime([ "app", @@ -72,13 +67,20 @@ describe("app create", () => { scope: { organization: "Your organization", environment: "your environment" }, }); expect(readFileSync(join(directory, "bun.lock"), "utf8")).toContain('"name": "product-pulse"'); - expect(await installedRuntimeIntegrity(directory)).toEqual(currentRuntimeIntegrity()); + expect( + JSON.parse(readFileSync(join(directory, "package.json"), "utf8")).dependencies[ + "@altertable/data-app" + ], + ).toBe(recommendedDataAppVersion()); + expect(existsSync(join(directory, ".altertable/runtime"))).toBe(false); const paths = JSON.parse(result.stdout[0]!).files as string[]; - expect(paths).toContain(".altertable/runtime/src/react/ui/PlayStory.tsx"); + expect(paths.some((path) => path.startsWith(".altertable/"))).toBe(false); expect(paths).toContain("src/App.tsx"); expect(paths).toContain(".oxlintrc.json"); expect(paths).toContain("docs/data.md"); - expect(paths).toContain(".altertable/runtime/README.md"); + expect(readFileSync(join(directory, "AGENTS.md"), "utf8")).toContain( + "node_modules/@altertable/data-app/AGENTS.md", + ); expect(readFileSync(join(directory, "src/App.tsx"), "utf8")).toContain( "Connectivity-only screen", ); @@ -88,19 +90,6 @@ describe("app create", () => { expect(readFileSync(join(directory, "AGENTS.md"), "utf8")).toContain( "connectivity screen is scaffolding, not an example analysis", ); - expect(Bun.spawnSync(["git", "init", "--quiet"], { cwd: directory }).exitCode).toBe(0); - const ignored = Bun.spawnSync( - [ - "git", - "-c", - "core.excludesFile=/dev/null", - "check-ignore", - "--no-index", - ".altertable/runtime/package.json", - ], - { cwd: directory }, - ); - expect(ignored.exitCode).toBe(1); expect(paths.some((path) => /tests|fixtures|node_modules|\.txt$/.test(path))).toBe(false); }); @@ -368,131 +357,30 @@ describe("app create", () => { expect(JSON.parse(result.stdout[0]!)).toMatchObject({ name: "agent-app", directory }); }); - test("upgrade preserves app-owned changes and refuses a modified runtime", async () => { - const directory = join(home, "upgrade-app"); + test("upgrade reports the tested package version without changing a current app", async () => { + const directory = join(home, "current-app"); await runCommandWithTestRuntime([ "app", "create", - "upgrade-app", + "current-app", "--dir", directory, "--without-profile", ]); const operations = join(directory, "src/operations.ts"); writeFileSync(operations, `${readFileSync(operations, "utf8")}\n// App-specific change.\n`); - const current = await runCommandWithTestRuntime(["app", "upgrade", "--dir", directory], { - debug: false, - json: false, - agent: false, - }); - expect(current.stdout.join("\n")).toContain("already current"); - expect(readFileSync(operations, "utf8")).toContain("App-specific change"); - for (const mode of [ { debug: false, json: true, agent: false }, { debug: false, json: false, agent: true }, ]) { - const output = await runCommandWithTestRuntime(["app", "upgrade", "--dir", directory], mode); - expect(JSON.parse(output.stdout[0]!)).toEqual({ + const result = await runCommandWithTestRuntime(["app", "upgrade", "--dir", directory], mode); + expect(JSON.parse(result.stdout[0]!)).toEqual({ directory, upgraded: false, - runtimeVersion: currentRuntimeIntegrity().version, + runtimeVersion: recommendedDataAppVersion(), nextSteps: [], }); } - - const runtime = join(directory, ".altertable/runtime/src/server/index.ts"); - writeFileSync(runtime, `${readFileSync(runtime, "utf8")}\n// Local edit.\n`); - expect(runCommandWithTestRuntime(["app", "upgrade", "--dir", directory])).rejects.toThrow( - "was modified", - ); - expect(readFileSync(runtime, "utf8")).toContain("Local edit"); - }); - - test("source watch upgrade updates integrity and stops on generated edits", async () => { - const directory = join(home, "watched-app"); - await runCommandWithTestRuntime([ - "app", - "create", - "watched-app", - "--dir", - directory, - "--without-profile", - ]); - const files = await readRuntimeSource(); - files["src/core/format.ts"] += "\n// Changed source.\n"; - expect(await upgradeApp(directory, { runtimeFiles: files })).toBe(true); - expect( - readFileSync(join(directory, ".altertable/runtime/src/core/format.ts"), "utf8"), - ).toContain("Changed source"); - expect((await installedRuntimeIntegrity(directory)).sha256).toEqual( - currentRuntimeIntegrity(files).sha256, - ); - - const generated = join(directory, ".altertable/runtime/src/core/format.ts"); - writeFileSync(generated, `${readFileSync(generated, "utf8")}\n// App edit.\n`); - expect(upgradeApp(directory, { runtimeFiles: await readRuntimeSource() })).rejects.toThrow( - "format.ts was modified", - ); - }); - - test("invalid lockfile leaves the installed runtime unchanged", async () => { - const directory = join(home, "invalid-lock-app"); - await runCommandWithTestRuntime([ - "app", - "create", - "invalid-lock-app", - "--dir", - directory, - "--without-profile", - ]); - const integrityPath = join(directory, ".altertable/runtime/integrity.json"); - const integrity = JSON.parse(readFileSync(integrityPath, "utf8")) as { version: string }; - integrity.version = "0.1.0"; - writeFileSync(integrityPath, `${JSON.stringify(integrity, null, 2)}\n`); - const runtimePath = join(directory, ".altertable/runtime/src/server/index.ts"); - const beforeRuntime = readFileSync(runtimePath, "utf8"); - const beforeIntegrity = readFileSync(integrityPath, "utf8"); - const lockPath = join(directory, "bun.lock"); - const validLock = readFileSync(lockPath, "utf8"); - rmSync(lockPath); - expect(upgradeApp(directory)).rejects.toThrow("valid bun.lock"); - expect(readFileSync(integrityPath, "utf8")).toBe(beforeIntegrity); - writeFileSync(lockPath, "{ invalid lockfile"); - - expect(upgradeApp(directory)).rejects.toThrow("valid bun.lock"); - expect(readFileSync(runtimePath, "utf8")).toBe(beforeRuntime); - expect(readFileSync(integrityPath, "utf8")).toBe(beforeIntegrity); - writeFileSync(lockPath, validLock); - expect(await upgradeApp(directory)).toBe(true); - }); - - test("upgrade rolls back a failure after applying runtime files", async () => { - const directory = join(home, "rollback-app"); - await runCommandWithTestRuntime([ - "app", - "create", - "rollback-app", - "--dir", - directory, - "--without-profile", - ]); - const integrityPath = join(directory, ".altertable/runtime/integrity.json"); - const integrity = JSON.parse(readFileSync(integrityPath, "utf8")) as { version: string }; - integrity.version = "0.1.0"; - writeFileSync(integrityPath, `${JSON.stringify(integrity, null, 2)}\n`); - const paths = [integrityPath, join(directory, "package.json"), join(directory, "bun.lock")]; - const before = paths.map((path) => readFileSync(path, "utf8")); - - expect( - upgradeApp(directory, { - afterApply(path) { - if (path === join(directory, ".altertable/runtime")) - throw new Error("Injected write failure"); - }, - }), - ).rejects.toThrow("Injected write failure"); - expect(paths.map((path) => readFileSync(path, "utf8"))).toEqual(before); - expect(await upgradeApp(directory)).toBe(true); + expect(readFileSync(operations, "utf8")).toContain("App-specific change"); }); }); diff --git a/cli/src/commands/app/dev.ts b/cli/src/commands/app/dev.ts index 59f11ada..c21d0001 100644 --- a/cli/src/commands/app/dev.ts +++ b/cli/src/commands/app/dev.ts @@ -1,10 +1,7 @@ import { checkAppScope } from "@/commands/app/lib/scope.ts"; -import { watch } from "node:fs"; import { defineCommand } from "@/lib/command.ts"; -import { CliError, ConfigurationError, EXIT_GENERIC } from "@/lib/errors.ts"; +import { CliError, EXIT_GENERIC } from "@/lib/errors.ts"; import { startAppDevProxy } from "@/commands/app/lib/dev-proxy.ts"; -import { readRuntimeSource, runtimeSourceDirectory } from "@/commands/app/lib/runtime.ts"; -import { upgradeApp } from "@/commands/app/upgrade.ts"; import { appDirectory, assertAppOutputMode, @@ -19,31 +16,19 @@ export const appDevCommand = defineCommand({ examples: [ "altertable app dev", "altertable app dev --port 3022", - "altertable app dev --watch-runtime", "altertable --profile staging app dev --dir ./my-app", ], }, args: { dir: { type: "string", description: "App directory (default: current directory)." }, port: { type: "string", description: "Local dev server port (1–65535; default: app setting)." }, - "watch-runtime": { - type: "boolean", - description: "Upgrade generated runtime on source changes and restart preview.", - }, }, - async run({ args, execution, runtime, sink }) { + async run({ args, execution, runtime }) { assertAppOutputMode(runtime.context.json, runtime.context.agent); const port = appPort(args.port); const directory = appDirectory(args.dir); requireAppScripts(directory, ["dev"]); await checkAppScope(directory, execution.profile); - if (args["watch-runtime"]) { - try { - await upgradeApp(directory, { runtimeFiles: await readRuntimeSource() }); - } catch (error) { - throw new ConfigurationError(`Runtime source watch stopped: ${errorMessage(error)}`); - } - } const installExitCode = await runAppCommand("install", directory); if (installExitCode !== 0) return { exitCode: EXIT_GENERIC }; const proxy = startAppDevProxy(execution); @@ -52,9 +37,7 @@ export const appDevCommand = defineCommand({ ...proxy.environment, ...(port ? { PORT: port } : {}), }; - const exitCode = args["watch-runtime"] - ? await runWithRuntimeWatch(directory, environment, sink.writeHuman.bind(sink)) - : await runAppCommand("dev", directory, environment); + const exitCode = await runAppCommand("dev", directory, environment); return { exitCode: exitCode === 0 ? 0 : EXIT_GENERIC }; } finally { await proxy.stop(); @@ -62,84 +45,6 @@ export const appDevCommand = defineCommand({ }, }); -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -async function runWithRuntimeWatch( - directory: string, - environment: Record, - announce: (message: string) => void, -): Promise { - let changed = false; - let wake: (() => void) | undefined; - let timer: ReturnType | undefined; - let watchedRoots = new Set( - Object.keys(await readRuntimeSource()).map((name) => name.split("/")[0]), - ); - let watcher: ReturnType; - try { - watcher = watch(runtimeSourceDirectory, { recursive: true }, (_, filename) => { - if (filename && !watchedRoots.has(filename.replaceAll("\\", "/").split("/")[0])) return; - if (timer) clearTimeout(timer); - timer = setTimeout(() => { - changed = true; - wake?.(); - }, 200); - }); - } catch (error) { - throw new ConfigurationError( - `Cannot watch runtime source at ${runtimeSourceDirectory}: ${errorMessage(error)}`, - ); - } - function waitForChange(): Promise { - if (changed) { - changed = false; - return Promise.resolve(); - } - return new Promise((resolve) => { - wake = () => { - wake = undefined; - changed = false; - resolve(); - }; - }); - } - try { - announce(`Watching runtime source at ${runtimeSourceDirectory}.`); - while (true) { - const controller = new AbortController(); - const preview = runAppCommand("dev", directory, environment, controller.signal); - const result = await Promise.race([ - preview.then((exitCode) => ({ kind: "exit" as const, exitCode })), - waitForChange().then(() => ({ kind: "change" as const })), - ]); - if (result.kind === "exit") return result.exitCode; - controller.abort(); - await preview; - try { - const files = await readRuntimeSource(); - watchedRoots = new Set(Object.keys(files).map((name) => name.split("/")[0])); - const upgraded = await upgradeApp(directory, { runtimeFiles: files }); - if (upgraded) { - const installExitCode = await runAppCommand("install", directory); - if (installExitCode !== 0) return installExitCode; - } - announce( - upgraded - ? "Runtime upgraded; restarting preview." - : "Runtime source unchanged; restarting preview.", - ); - } catch (error) { - throw new ConfigurationError(`Runtime source watch stopped: ${errorMessage(error)}`); - } - } - } finally { - if (timer) clearTimeout(timer); - watcher.close(); - } -} - function appPort(value: unknown): string | undefined { if (value === undefined) return undefined; if (typeof value !== "string" || !/^[1-9]\d{0,4}$/.test(value) || Number(value) > 65535) { diff --git a/cli/src/commands/app/lib/distribution.test.ts b/cli/src/commands/app/lib/distribution.test.ts index 8df2dc9f..c8412f64 100644 --- a/cli/src/commands/app/lib/distribution.test.ts +++ b/cli/src/commands/app/lib/distribution.test.ts @@ -1,13 +1,14 @@ import { afterEach, expect, test } from "bun:test"; -import { cp, mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { cp, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, posix } from "node:path"; import { createAppFiles, dataAppDirectory, readDataAppPayload, - readRuntimeSource, + starterFiles, } from "@/commands/app/lib/distribution.ts"; +import { recommendedDataAppVersion } from "@/commands/app/lib/package.ts"; const directories: string[] = []; afterEach(async () => { @@ -16,12 +17,12 @@ afterEach(async () => { ); }); -test("distribution is deterministic, source-based, and excludes development files", async () => { +test("distribution embeds a deterministic starter with an exact package pin and no runtime", async () => { const payload = await readDataAppPayload(); expect(await readDataAppPayload()).toEqual(payload); - expect(Object.keys(payload.runtime)).toContain("src/react/ui/PlayStory.tsx"); + expect(Object.keys(payload)).toEqual(["starter"]); expect(Object.keys(payload.starter)).toContain("src/App.tsx"); - for (const path of [...Object.keys(payload.runtime), ...Object.keys(payload.starter)]) { + for (const path of Object.keys(payload.starter)) { expect(path).not.toMatch(/(^|\/)(tests|fixtures|node_modules|dist|\.altertable)(\/|$)|\.txt$/); } const identity = { @@ -34,54 +35,55 @@ test("distribution is deterministic, source-based, and excludes development file title: identity.title, scope: identity.scope, }); + expect(JSON.parse(files["package.json"]!).dependencies["@altertable/data-app"]).toBe( + recommendedDataAppVersion(), + ); expect(files["src/App.tsx"]).toBe(payload.starter["src/App.tsx"]); - expect(files["src/server.ts"]).toBe(payload.starter["src/server.ts"]); - expect(files[".oxlintrc.json"]).toBe(payload.starter[".oxlintrc.json"]); + expect(files["src/server.ts"]).toContain("@altertable/data-app/server/bun"); + expect(files["src/main.tsx"]).toContain("@altertable/data-app/react/styles.css"); expect(files["bun.lock"]).toContain('"name": "example"'); }); -test("new runtime source files enter the payload without a manual inventory", async () => { - const directory = await mkdtemp(join(tmpdir(), "runtime-distribution-")); +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), "starter-distribution-")); directories.push(directory); - await cp(join(dataAppDirectory, "runtime", "src"), join(directory, "src"), { recursive: true }); - await writeFile( - join(directory, "package.json"), - JSON.stringify({ files: ["package.json", "src"] }), - ); - await writeFile(join(directory, "src/new.ts"), "export const added = true;\n"); - expect((await readRuntimeSource(directory))["src/new.ts"]).toContain("added"); - await writeFile(join(directory, "src/new.test.ts"), "test artifact"); - expect(readRuntimeSource(directory)).rejects.toThrow("Development artifact"); -}); + for (const name of starterFiles) + await cp(join(dataAppDirectory, "starter", name), join(directory, name), { recursive: true }); + return directory; +} -test("distribution rejects paths outside its source and symlinks", async () => { - const directory = await mkdtemp(join(tmpdir(), "runtime-distribution-")); - directories.push(directory); - await writeFile(join(directory, "package.json"), JSON.stringify({ files: ["../secret"] })); - expect(readRuntimeSource(directory)).rejects.toThrow("Invalid distribution path"); - await mkdir(join(directory, "src")); - await writeFile(join(directory, "package.json"), JSON.stringify({ files: ["src"] })); +test("distribution rejects symlinks and development artifacts inside starter source", async () => { + const directory = await fixture(); await symlink(join(directory, "package.json"), join(directory, "src/link.ts")); - expect(readRuntimeSource(directory)).rejects.toThrow("Symlink"); + // The payload root owns a starter directory; keep its allowlist identical to production. + const root = await mkdtemp(join(tmpdir(), "starter-payload-")); + directories.push(root); + await cp(directory, join(root, "starter"), { recursive: true, dereference: false }); + expect(readDataAppPayload(root)).rejects.toThrow("Symlink"); + await rm(join(root, "starter/src/link.ts")); + await writeFile(join(root, "starter/src/probe.test.ts"), "test artifact"); + expect(readDataAppPayload(root)).rejects.toThrow("Development artifact"); }); -test("generated authoring routes and runtime API links resolve inside the shipped project", async () => { - const payload = await readDataAppPayload(); - const files = createAppFiles(payload, { - name: "documentation-test", - title: "Documentation test", +test("authoring links resolve to app files or published package docs", async () => { + const files = createAppFiles(await readDataAppPayload(), { + name: "docs-test", + title: "Docs", scope: { organization: "Test", environment: "test" }, }); - expect(files["docs/data.md"]).toBeDefined(); - expect(files["docs/views.md"]).toBeDefined(); - expect(files[".altertable/runtime/README.md"]).toBeDefined(); + expect(files["AGENTS.md"]).toContain("node_modules/@altertable/data-app/AGENTS.md"); for (const [name, content] of Object.entries(files)) { if (!name.endsWith(".md")) continue; for (const match of content.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)) { const target = match[1]!.split("#")[0]!; if (!target || /^[a-z]+:\/\//i.test(target)) continue; const path = posix.normalize(posix.join(posix.dirname(name), target)); - expect(files[path], `${name} links to missing ${path}`).toBeDefined(); + if (path.startsWith("node_modules/@altertable/data-app/")) { + expect( + await Bun.file(join(dataAppDirectory, "starter", path)).exists(), + `${name} links to missing ${path}`, + ).toBe(true); + } else expect(files[path], `${name} links to missing ${path}`).toBeDefined(); } } }); diff --git a/cli/src/commands/app/lib/distribution.ts b/cli/src/commands/app/lib/distribution.ts index d44fecb3..d7801016 100644 --- a/cli/src/commands/app/lib/distribution.ts +++ b/cli/src/commands/app/lib/distribution.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { lstat, readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -18,10 +17,8 @@ export const starterFiles = [ ".oxlintrc.json", ".oxfmtrc.json", ] as const; -export const runtimePath = ".altertable/runtime"; export const dataAppDirectory = fileURLToPath(new URL("../../../../../data-app/", import.meta.url)); -export const runtimeSourceDirectory = join(dataAppDirectory, "runtime"); -export type DataAppPayload = { starter: Record; runtime: Record }; +export type DataAppPayload = { starter: Record }; function assertRelativePath(path: string): void { if ( @@ -56,39 +53,9 @@ async function readFiles( return files; } -export async function readRuntimeSource( - directory = runtimeSourceDirectory, -): Promise> { - const manifest = JSON.parse(await readFile(join(directory, "package.json"), "utf8")) as { - files?: unknown; - }; - if (!Array.isArray(manifest.files) || !manifest.files.every((file) => typeof file === "string")) { - throw new Error("Runtime package.json needs a files allowlist."); - } - return readFiles(directory, manifest.files); -} - +/** Release builds embed only the starter; its runtime is installed from npm. */ export async function readDataAppPayload(directory = dataAppDirectory): Promise { - const [starter, runtime] = await Promise.all([ - readFiles(join(directory, "starter"), starterFiles), - readRuntimeSource(join(directory, "runtime")), - ]); - return { starter, runtime }; -} - -export function runtimeIntegrity(files: Record): { - version: string; - sha256: Record; -} { - return { - version: (JSON.parse(files["package.json"]!) as { version: string }).version, - sha256: Object.fromEntries( - Object.entries(files).map(([name, content]) => [ - name, - createHash("sha256").update(content).digest("hex"), - ]), - ), - }; + return { starter: await readFiles(join(directory, "starter"), starterFiles) }; } export function createAppFiles( @@ -102,7 +69,6 @@ export function createAppFiles( const files = { ...payload.starter }; const packageJson = JSON.parse(files["package.json"]!); packageJson.name = identity.name; - packageJson.dependencies["@altertable/data-app"] = `file:${runtimePath}`; files["package.json"] = `${JSON.stringify(packageJson, null, 2)}\n`; const app = JSON.parse(files["app.json"]!); app.title = identity.title; @@ -114,9 +80,5 @@ export function createAppFiles( `"name": ${oldName}`, `"name": ${JSON.stringify(identity.name)}`, ); - for (const [name, content] of Object.entries(payload.runtime)) - files[`${runtimePath}/${name}`] = content; - files[`${runtimePath}/integrity.json`] = - `${JSON.stringify(runtimeIntegrity(payload.runtime), null, 2)}\n`; return files; } diff --git a/cli/src/commands/app/lib/legacy-app.ts b/cli/src/commands/app/lib/legacy-app.ts new file mode 100644 index 00000000..b393783e --- /dev/null +++ b/cli/src/commands/app/lib/legacy-app.ts @@ -0,0 +1,176 @@ +import { lstat, readdir, readFile } from "node:fs/promises"; +import { dirname, join, relative, resolve } from "node:path"; +import { ConfigurationError } from "@/lib/errors.ts"; + +/** Only rewrite public import paths and documentation links; preserve app-owned logic. */ +export async function legacyAppEdits(directory: string): Promise> { + const edits = new Map(); + const sources: string[] = []; + async function visit(path: string): Promise { + for (const entry of await readdir(join(directory, path), { withFileTypes: true })) { + const name = join(path, entry.name); + if (entry.isSymbolicLink()) { + throw new ConfigurationError( + `Cannot migrate symlink ${name}. Update its package imports manually before upgrading.`, + ); + } + if (entry.isDirectory()) await visit(name); + else if (entry.isFile() && /\.[cm]?[jt]sx?$/.test(name)) sources.push(name); + } + } + await visit("src"); + for (const name of sources) { + const source = await readFile(join(directory, name), "utf8"); + const removed = [ + "StorySection", + "PlayStory", + "StorySectionProps", + "PlayStoryProps", + "StoryStep", + ]; + const reactImports = [ + ...source.matchAll( + /(?:import|export)\s+(?:type\s+)?\{([^}]+)\}\s+from\s+["']@altertable\/data-app\/react["']/g, + ), + ]; + if ( + reactImports.some((match) => + match[1]!.split(",").some((binding) => + removed.includes( + binding + .trim() + .replace(/^type\s+/, "") + .split(/\s+/)[0]!, + ), + ), + ) || + /\bstory\s*=\s*\{\s*\{/.test(source) || + /\.storyStep\s*\(/.test(source) + ) { + throw new ConfigurationError( + `Migrate story authoring in ${name} before upgrading: StorySection → DataWidget, PlayStory → PresentStory, context.storyStep → context.finding, and DataApp.story → a snapshot function with explicit evidence. See https://github.com/altertable-ai/data-app/blob/main/docs/react.md#migration-from-the-earlier-runtime. No app files were changed.`, + ); + } + const next = source.replace( + /^(\s*)(import|export)\s+(type\s+)?\{([^}]+)\}\s+from\s+(["'])@altertable\/data-app\/server\5[ \t]*;?[ \t]*(\/\/[^\n]*|\/\*[^\n]*\*\/)?/gm, + ( + statement, + indent: string, + keyword: string, + type: string | undefined, + bindings: string, + quote: string, + comment: string | undefined, + ) => { + if (type) return statement; + const entries = bindings + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + const local = entries.filter((value) => + /^(?:serveLocalApp|localLakehouse)(?:\s|$)/.test(value), + ); + if (!local.length) return statement; + const portable = entries.filter((value) => !local.includes(value)); + const statements = [ + `${keyword} { ${local.join(", ")} } from ${quote}@altertable/data-app/server/bun${quote};`, + ]; + if (portable.length) + statements.unshift( + `${keyword} { ${portable.join(", ")} } from ${quote}@altertable/data-app/server${quote};`, + ); + const note = comment && comment.trimStart(); + if (note && portable.length) return `${indent}${note}\n${statements.join("\n")}`; + return indent + statements.join("\n") + (note ? ` ${note}` : ""); + }, + ); + if ( + /(?:import|export)\s+\*[^;]*["']@altertable\/data-app\/server["']|import\s*\(\s*["']@altertable\/data-app\/server["']/.test( + next, + ) + ) { + throw new ConfigurationError( + `Cannot safely migrate server imports in ${name}. Import serveLocalApp and localLakehouse from @altertable/data-app/server/bun, then retry.`, + ); + } + if (source !== next) edits.set(name, next); + } + const stylesheet = "@altertable/data-app/react/styles.css"; + const hasStyles = ( + await Promise.all( + sources.map(async (name) => + (edits.get(name) ?? (await readFile(join(directory, name), "utf8"))).includes(stylesheet), + ), + ) + ).some(Boolean); + if (!hasStyles) { + const html = await readFile(join(directory, "src/index.html"), "utf8"); + const script = [...html.matchAll(/]*)>/gi)].find((match) => + /\btype\s*=\s*["']module["']/i.test(match[1]!), + ); + const reference = script?.[1]?.match(/\bsrc\s*=\s*["']([^"']+)["']/i)?.[1]; + const path = reference && relative(directory, resolve(directory, "src", reference)); + if (!path || !sources.includes(path) || !(await lstat(join(directory, path))).isFile()) { + throw new ConfigurationError( + `Cannot identify the browser entry. Import "${stylesheet}" in your browser entry, then retry app upgrade.`, + ); + } + edits.set( + path, + `import "${stylesheet}";\n${edits.get(path) ?? (await readFile(join(directory, path), "utf8"))}`, + ); + } + async function migrateDocs(path: string): Promise { + const absolute = join(directory, path); + let stat; + try { + stat = await lstat(absolute); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + if (stat.isDirectory()) { + for (const name of await readdir(absolute)) await migrateDocs(join(path, name)); + } else if (stat.isFile() && path.endsWith(".md")) { + const source = await readFile(absolute, "utf8"); + const prefix = relative(dirname(path), "node_modules/@altertable/data-app").replaceAll( + "\\", + "/", + ); + let next = source + .replace( + /\]\((?:\.\.\/)*\.?\/?\.altertable\/runtime\/README\.md(?:#([^)]*))?\)/g, + (_, anchor: string | undefined) => { + const doc = + anchor === "execute-named-queries" + ? "contract.md" + : anchor === "entry-points" || !anchor + ? "app-authoring.md" + : "react.md"; + return `](${prefix}/docs/${doc}${anchor && doc === "react.md" ? `#${anchor}` : ""})`; + }, + ) + .replace( + /\]\((?:\.\.\/)*\.?\/?\.altertable\/runtime\/src\/server\/index\.ts\)/g, + `](${prefix}/docs/server.md)`, + ); + next = next + .replace("Read `.altertable/runtime/`", "Read the installed package docs") + .replace( + "CLI runtime contributors can use `altertable app dev --watch-runtime` to upgrade the generated runtime and restart the preview when runtime source changes. The watcher stops if a generated runtime file was edited.", + "Runtime development belongs to the standalone Data App package repository.", + ) + .replace( + "Commit `.altertable/runtime/`, including its integrity record, together with `package.json` and `bun.lock`. It is a local package required by a fresh clone. Keep custom code in `src/` and use `altertable app upgrade` to replace the managed runtime.", + "Commit app source, package.json, and bun.lock. Install the published package with bun install --frozen-lockfile. Keep custom code in src/ and use altertable app upgrade to update the tested package pin.", + ); + if (path === "AGENTS.md" && !next.includes("node_modules/@altertable/data-app/AGENTS.md")) { + next += + "\nRead `node_modules/@altertable/data-app/AGENTS.md` and its `docs/app-authoring.md` for public package usage. Do not edit installed package files.\n"; + } + if (source !== next) edits.set(path, next); + } + } + for (const path of ["AGENTS.md", "README.md", "docs"]) await migrateDocs(path); + return edits; +} diff --git a/cli/src/commands/app/lib/runtime.ts b/cli/src/commands/app/lib/legacy-runtime.ts similarity index 50% rename from cli/src/commands/app/lib/runtime.ts rename to cli/src/commands/app/lib/legacy-runtime.ts index acc535b4..1dbef6cd 100644 --- a/cli/src/commands/app/lib/runtime.ts +++ b/cli/src/commands/app/lib/legacy-runtime.ts @@ -1,31 +1,18 @@ import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; +import { lstat, readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { ConfigurationError } from "@/lib/errors.ts"; -import { dataAppPayload } from "@/commands/app/lib/payload.ts"; -import { runtimeIntegrity, runtimePath } from "@/commands/app/lib/distribution.ts"; - -export { - runtimePath, - runtimeSourceDirectory, - readRuntimeSource, -} from "@/commands/app/lib/distribution.ts"; -export const runtimeFiles = dataAppPayload.runtime; +import { legacyRuntimePath } from "@/commands/app/lib/package.ts"; +import { isRecord } from "@/lib/object.ts"; type RuntimeIntegrity = { version: string; sha256: Record }; -export type InstalledRuntime = RuntimeIntegrity; +type InstalledRuntime = RuntimeIntegrity; function hash(content: string): string { return createHash("sha256").update(content).digest("hex"); } -export function currentRuntimeIntegrity( - files: Record = runtimeFiles, -): RuntimeIntegrity { - return runtimeIntegrity(files); -} - -export async function installedRuntimeIntegrity(directory: string): Promise { - const path = runtimePath; +export async function validateLegacyRuntime(directory: string): Promise { + const path = legacyRuntimePath; let integrity: RuntimeIntegrity; try { integrity = JSON.parse( @@ -37,15 +24,21 @@ export async function installedRuntimeIntegrity(directory: string): Promise !part || part === "." || part === "..")) { + if ( + name.includes("\\") || + name.split("/").some((part) => !part || part === "." || part === "..") || + typeof checksum !== "string" || + !/^[a-f0-9]{64}$/.test(checksum) + ) { throw new ConfigurationError("Data app runtime has an invalid integrity record."); } let content: string; @@ -60,5 +53,25 @@ export async function installedRuntimeIntegrity(directory: string): Promise { + const target = join(directory, path, relative); + const stat = await lstat(target); + if (stat.isSymbolicLink()) + throw new ConfigurationError( + `Data app runtime ${relative || path} is a symlink. Restore the managed runtime before upgrading.`, + ); + if (stat.isDirectory()) { + for (const name of await readdir(target)) + await visit(relative ? `${relative}/${name}` : name); + } else if ( + !stat.isFile() || + (relative !== "integrity.json" && !Object.hasOwn(integrity.sha256, relative)) + ) { + throw new ConfigurationError( + `Data app runtime contains unmanaged file ${relative}. Move custom files outside ${path}/ before upgrading.`, + ); + } + } + await visit(""); return integrity; } diff --git a/cli/src/commands/app/lib/package.test.ts b/cli/src/commands/app/lib/package.test.ts new file mode 100644 index 00000000..af8ab03d --- /dev/null +++ b/cli/src/commands/app/lib/package.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { dataAppPackage, requirePublishedDataApp } from "@/commands/app/lib/package.ts"; + +let directory: string; +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "app-package-check-")); +}); +afterEach(async () => { + await rm(directory, { recursive: true, force: true }); +}); + +async function fixture(reference: string, lockedReference = reference, version = "0.59.0") { + await writeFile( + join(directory, "package.json"), + JSON.stringify({ dependencies: { [dataAppPackage]: reference } }), + ); + await writeFile( + join(directory, "bun.lock"), + JSON.stringify({ + lockfileVersion: 2, + workspaces: { "": { dependencies: { [dataAppPackage]: lockedReference } } }, + packages: { [dataAppPackage]: [`${dataAppPackage}@${version}`, "", {}, "integrity"] }, + }), + ); +} + +test.each([ + ["file:.altertable/runtime", "app upgrade"], + ["file:../local-runtime", "published"], + ["github:altertable-ai/data-app", "published"], +])("check rejects %s before installing or modifying the lockfile", async (reference, message) => { + await fixture(reference!); + const before = await readFile(join(directory, "bun.lock"), "utf8"); + const failure = await requirePublishedDataApp(directory).catch((error: unknown) => error); + expect(failure).toMatchObject({ message: expect.stringContaining(message!) }); + expect(await readFile(join(directory, "bun.lock"), "utf8")).toBe(before); +}); + +test("check rejects a stale lockfile without resolving a replacement", async () => { + await fixture("0.59.1", "0.59.0"); + const before = await readFile(join(directory, "bun.lock"), "utf8"); + const failure = await requirePublishedDataApp(directory).catch((error: unknown) => error); + expect(failure).toMatchObject({ message: expect.stringContaining("matching package.json") }); + expect(await readFile(join(directory, "bun.lock"), "utf8")).toBe(before); +}); + +test("check accepts an older registry package independent of the CLI starter pin", async () => { + await fixture("^0.59.0"); + await requirePublishedDataApp(directory); +}); diff --git a/cli/src/commands/app/lib/package.ts b/cli/src/commands/app/lib/package.ts new file mode 100644 index 00000000..388fbe23 --- /dev/null +++ b/cli/src/commands/app/lib/package.ts @@ -0,0 +1,175 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { ConfigurationError } from "@/lib/errors.ts"; +import { isRecord } from "@/lib/object.ts"; +import { dataAppPayload } from "@/commands/app/lib/payload.ts"; + +export const dataAppPackage = "@altertable/data-app"; +export const legacyRuntimePath = ".altertable/runtime"; + +type AppPackage = { + dependencies?: Record; + devDependencies?: Record; +}; + +export function recommendedDataAppVersion(): string { + const version = JSON.parse(dataAppPayload.starter["package.json"]!).dependencies[dataAppPackage]; + if (typeof version !== "string" || !/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(version)) { + throw new ConfigurationError("The CLI starter must pin a published data app version."); + } + return version; +} + +export async function readAppPackage( + directory: string, +): Promise<{ source: string; manifest: AppPackage }> { + try { + const source = await readFile(join(directory, "package.json"), "utf8"); + const manifest: unknown = JSON.parse(source); + if ( + !isRecord(manifest) || + !isRecord(manifest.dependencies) || + Object.values(manifest.dependencies).some((value) => typeof value !== "string") + ) + throw new Error(); + return { source, manifest: manifest as AppPackage }; + } catch { + throw new ConfigurationError(`Cannot read a valid package.json in ${directory}.`); + } +} + +export async function requirePublishedDataApp(directory: string): Promise { + const { manifest } = await readAppPackage(directory); + if (manifest.dependencies?.[dataAppPackage] === `file:${legacyRuntimePath}`) { + throw new ConfigurationError( + "This app uses a vendored runtime. Run `altertable app upgrade` to migrate it to the published package.", + ); + } + const reference = manifest.dependencies?.[dataAppPackage]; + if (!reference || !/^[\d~^<>=*]/.test(reference)) { + throw new ConfigurationError( + `package.json needs a published ${dataAppPackage} version or range.`, + ); + } + let lock: string; + try { + lock = await readFile(join(directory, "bun.lock"), "utf8"); + } catch { + throw new ConfigurationError(`Cannot read a valid bun.lock in ${directory}.`); + } + const { packages } = readAppLock(lock, directory, reference); + const version = lockedVersion(packages, dataAppPackage); + if (!version || !Bun.semver.satisfies(version, reference)) { + throw new ConfigurationError( + "bun.lock does not resolve the declared data app package. Run bun install before checking.", + ); + } +} + +export function readAppLock( + source: string, + directory: string, + reference?: string, +): { + packages: Record; +} { + try { + const value = parseJsonc(source); + const workspaces = isRecord(value) && value.workspaces; + const root = isRecord(workspaces) && workspaces[""]; + const dependencies = isRecord(root) && root.dependencies; + if ( + !isRecord(value) || + !isRecord(value.packages) || + !isRecord(dependencies) || + (reference !== undefined && dependencies[dataAppPackage] !== reference) + ) + throw new Error(); + return { packages: value.packages }; + } catch { + throw new ConfigurationError( + `Cannot read a valid bun.lock matching package.json in ${directory}.`, + ); + } +} + +export function lockedVersion(packages: Record, name: string): string | undefined { + const entry = packages[name]; + if (!Array.isArray(entry) || typeof entry[0] !== "string") return; + const version = entry[0].slice(`${name}@`.length); + return entry[0].startsWith(`${name}@`) && /^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(version) + ? version + : undefined; +} + +export function recommendedDataAppPeers(): Record { + const { packages } = readAppLock( + dataAppPayload.starter["bun.lock"]!, + "CLI starter", + recommendedDataAppVersion(), + ); + const entry = packages[dataAppPackage]; + const metadata = + Array.isArray(entry) && + entry.find((value) => isRecord(value) && isRecord(value.peerDependencies)); + if ( + !isRecord(metadata) || + !isRecord(metadata.peerDependencies) || + Object.values(metadata.peerDependencies).some((value) => typeof value !== "string") + ) { + throw new ConfigurationError("The CLI starter lockfile needs data app peer dependencies."); + } + return metadata.peerDependencies as Record; +} + +function parseJsonc(source: string): unknown { + let withoutComments = ""; + let inString = false; + let escaped = false; + for (let index = 0; index < source.length; index++) { + const character = source[index]!; + const next = source[index + 1]; + if (inString) { + withoutComments += character; + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === '"') inString = false; + } else if (character === '"') { + inString = true; + withoutComments += character; + } else if (character === "/" && next === "/") { + while (index < source.length && source[index] !== "\n") index++; + withoutComments += "\n"; + } else if (character === "/" && next === "*") { + index += 2; + while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) + index++; + if (index >= source.length) throw new Error("Unterminated JSONC comment"); + index++; + withoutComments += " "; + } else { + withoutComments += character; + } + } + if (inString) throw new Error("Unterminated JSONC string"); + let withoutTrailingCommas = ""; + inString = false; + escaped = false; + for (let index = 0; index < withoutComments.length; index++) { + const character = withoutComments[index]!; + if (inString) { + withoutTrailingCommas += character; + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === '"') inString = false; + } else if (character === '"') { + inString = true; + withoutTrailingCommas += character; + } else if (character === "," && /^[\s]*[}\]]/.test(withoutComments.slice(index + 1))) { + continue; + } else { + withoutTrailingCommas += character; + } + } + return JSON.parse(withoutTrailingCommas) as unknown; +} diff --git a/cli/src/commands/app/lib/payload.ts b/cli/src/commands/app/lib/payload.ts index 4cfdde15..396b8a1b 100644 --- a/cli/src/commands/app/lib/payload.ts +++ b/cli/src/commands/app/lib/payload.ts @@ -1,4 +1,4 @@ import { readDataAppPayload } from "@/commands/app/lib/distribution.ts"; -// Source execution reads canonical projects. Release builds replace this module with literal data. +// Source execution reads the starter template. Release builds replace this module with literal data. export const dataAppPayload = await readDataAppPayload(); diff --git a/cli/src/commands/app/lib/project-check.js.txt b/cli/src/commands/app/lib/project-check.js.txt index a1038a53..7e681d1b 100644 --- a/cli/src/commands/app/lib/project-check.js.txt +++ b/cli/src/commands/app/lib/project-check.js.txt @@ -7,7 +7,7 @@ const appFile = (path) => pathToFileURL(join(process.cwd(), path)).href; async function check() { const manifest = JSON.parse(await readFile("app.json", "utf8")); try { - const { parseAppearance } = await import(appFile(".altertable/runtime/src/core/appearance.ts")); + const { parseAppearance } = await import("@altertable/data-app/appearance"); parseAppearance(manifest.appearance); } catch { throw new Error("app.json has invalid appearance settings."); @@ -63,12 +63,15 @@ async function check() { plugins: [{ name: "data-app-browser-boundary", setup(build) { - build.onLoad({ filter: /(?:operations|server|index|handler|local)\.[cm]?[jt]sx?$/ }, ({ path }) => { + build.onResolve({ filter: /^@altertable\/data-app\/server(?:\/|$)/ }, () => { + throw new Error("Browser code cannot import server transport. Use import type for server types."); + }); + build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, ({ path }) => { const normalized = path.replaceAll("\\", "/"); const root = process.cwd().replaceAll("\\", "/"); if (normalized === `${root}/src/operations.ts` || normalized === `${root}/src/server.ts` || - /(?:\.altertable\/runtime|node_modules\/@altertable\/data-app)\/src\/server\/(?:index|handler|local)\.ts$/.test(normalized)) { + /node_modules\/@altertable\/data-app\/(?:dist\/(?:server|local)\.js|src\/server\/[^/]+\.[jt]s)$/.test(normalized)) { throw new Error("Browser code cannot import server operations or transport. Use import type for operation types."); } }); @@ -79,8 +82,8 @@ async function check() { } if (process.argv[1] !== "--lakehouse") return; - const { createDataHandler } = await import(appFile(".altertable/runtime/src/server/index.ts")); - const { localLakehouse } = await import(appFile(".altertable/runtime/src/server/local.ts")); + const { createDataHandler } = await import("@altertable/data-app/server"); + const { localLakehouse } = await import("@altertable/data-app/server/bun"); const handle = createDataHandler(operations, async () => ({ lakehouse: localLakehouse(process.env), canDiscloseSql: true, diff --git a/cli/src/commands/app/lib/run.ts b/cli/src/commands/app/lib/run.ts index 11039d8b..95993505 100644 --- a/cli/src/commands/app/lib/run.ts +++ b/cli/src/commands/app/lib/run.ts @@ -4,7 +4,7 @@ import { CliError, ConfigurationError } from "@/lib/errors.ts"; import { copyProcessEnv } from "@/lib/env.ts"; export type AppScript = "typecheck" | "lint" | "format:check" | "dev" | "build"; -export type AppCommand = "install" | AppScript; +export type AppCommand = "install" | "install-frozen" | AppScript; export function appDirectory(value: unknown): string { if (value !== undefined && (typeof value !== "string" || value.length === 0)) { @@ -57,7 +57,12 @@ export async function runAppCommand( // command and supports `bun` calls inside the project's scripts. env.BUN_BE_BUN = "1"; - const arguments_ = command === "install" ? ["install"] : ["run", command]; + const arguments_ = + command === "install" + ? ["install"] + : command === "install-frozen" + ? ["install", "--frozen-lockfile"] + : ["run", command]; const child = Bun.spawn([process.execPath, ...arguments_], { cwd: directory, env, diff --git a/cli/src/commands/app/upgrade.test.ts b/cli/src/commands/app/upgrade.test.ts index e8cf08ba..280f43f6 100644 --- a/cli/src/commands/app/upgrade.test.ts +++ b/cli/src/commands/app/upgrade.test.ts @@ -1,13 +1,15 @@ import { afterEach, expect, test } from "bun:test"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import { createAppFiles, readDataAppPayload } from "@/commands/app/lib/distribution.ts"; import { - createAppFiles, - readDataAppPayload, - runtimeIntegrity, -} from "@/commands/app/lib/distribution.ts"; -import { installedRuntimeIntegrity } from "@/commands/app/lib/runtime.ts"; + dataAppPackage, + legacyRuntimePath, + readAppLock, + recommendedDataAppVersion, +} from "@/commands/app/lib/package.ts"; import { upgradeApp } from "@/commands/app/upgrade.ts"; const directories: string[] = []; @@ -16,163 +18,331 @@ afterEach(async () => { directories.splice(0).map((path) => rm(path, { recursive: true, force: true })), ); }); -async function fixture() { - const directory = await mkdtemp(join(tmpdir(), "runtime-upgrade-")); + +async function fixture(legacy = true) { + const directory = await mkdtemp(join(tmpdir(), "package-upgrade-")); directories.push(directory); const payload = await readDataAppPayload(); - for (const [name, content] of Object.entries( - createAppFiles(payload, { - name: "upgrade-test", - title: "Test", - scope: { organization: "Test", environment: "test" }, - }), - )) { + const files = createAppFiles(payload, { + name: "upgrade-test", + title: "Test", + scope: { organization: "Test", environment: "test" }, + }); + if (legacy) { + const pkg = JSON.parse(files["package.json"]!); + pkg.dependencies[dataAppPackage] = `file:${legacyRuntimePath}`; + pkg.scripts.custom = "echo user-owned"; + files["package.json"] = JSON.stringify(pkg); + const lock = readAppLock(files["bun.lock"]!, directory); + files["bun.lock"] = JSON.stringify({ + lockfileVersion: 2, + configVersion: 1, + workspaces: { "": { dependencies: pkg.dependencies } }, + packages: { + ...lock.packages, + [dataAppPackage]: [`${dataAppPackage}@file:${legacyRuntimePath}`, {}], + }, + }); + files["src/server.ts"] = files["src/server.ts"]!.replace("/server/bun", "/server"); + files["src/main.tsx"] = files["src/main.tsx"]!.replace( + 'import "@altertable/data-app/react/styles.css";\n', + "", + ); + files["AGENTS.md"] = "[Runtime map](.altertable/runtime/README.md#find-ui-by-task)\n"; + files["docs/data.md"] = + "[Named queries](../.altertable/runtime/README.md#execute-named-queries)\n"; + const runtime = { + "package.json": JSON.stringify({ name: dataAppPackage, version: "0.57.0" }), + "src/server.ts": "export const oldRuntime = true;\n", + }; + for (const [name, content] of Object.entries(runtime)) + files[`${legacyRuntimePath}/${name}`] = content; + files[`${legacyRuntimePath}/integrity.json`] = JSON.stringify({ + version: "0.57.0", + sha256: Object.fromEntries( + Object.entries(runtime).map(([name, content]) => [ + name, + createHash("sha256").update(content).digest("hex"), + ]), + ), + }); + } + for (const [name, content] of Object.entries(files)) { await mkdir(dirname(join(directory, name)), { recursive: true }); await writeFile(join(directory, name), content); } - return { directory, files: { ...payload.runtime } }; + return directory; } + +const tracked = [ + "package.json", + "bun.lock", + "src/server.ts", + "src/main.tsx", + "src/operations.ts", + "AGENTS.md", + "docs/data.md", + `${legacyRuntimePath}/integrity.json`, + `${legacyRuntimePath}/src/server.ts`, +]; async function snapshot(directory: string) { return Promise.all( - [ - "package.json", - "bun.lock", - ".altertable/runtime/integrity.json", - ".altertable/runtime/package.json", - ].map((name) => readFile(join(directory, name), "utf8")), + tracked.map(async (name) => [ + name, + await readFile(join(directory, name), "utf8").catch(() => null), + ]), ); } +const validateMigration = async () => {}; +async function expectUpgradeFailure(operation: () => Promise, message: string) { + const failure = await operation().catch((error: unknown) => error); + expect(failure).toMatchObject({ message: expect.stringContaining(message) }); +} + +async function resolveLock(directory: string) { + const payload = await readDataAppPayload(); + await writeFile(join(directory, "bun.lock"), payload.starter["bun.lock"]!); +} -test("dependency changes regenerate the lock and support a frozen installation", async () => { - const { directory, files } = await fixture(); - const pkg = JSON.parse(files["package.json"]!); - pkg.dependencies["@floating-ui/core"] = "1.7.5"; - files["package.json"] = JSON.stringify(pkg); - const originalApp = await readFile(join(directory, "src/App.tsx"), "utf8"); - const before = await readFile(join(directory, "bun.lock"), "utf8"); - expect(await upgradeApp(directory, { runtimeFiles: files })).toBe(true); - expect(await readFile(join(directory, "bun.lock"), "utf8")).not.toBe(before); +test("legacy migration installs the published artifact with a frozen lock and preserves app logic", async () => { + const directory = await fixture(); + const original = await readFile(join(directory, "src/operations.ts"), "utf8"); + expect(await upgradeApp(directory)).toBe(true); + expect(await Bun.file(join(directory, legacyRuntimePath, "package.json")).exists()).toBe(false); + expect(await readFile(join(directory, "src/operations.ts"), "utf8")).toBe(original); + expect(await readFile(join(directory, "src/server.ts"), "utf8")).toContain("/server/bun"); + expect(await readFile(join(directory, "src/main.tsx"), "utf8")).toContain("/react/styles.css"); + expect(await readFile(join(directory, "AGENTS.md"), "utf8")).toContain( + "node_modules/@altertable/data-app/docs/react.md", + ); + expect(await readFile(join(directory, "docs/data.md"), "utf8")).toContain( + "../node_modules/@altertable/data-app/docs/contract.md", + ); + expect(JSON.parse(await readFile(join(directory, "package.json"), "utf8"))).toMatchObject({ + dependencies: { [dataAppPackage]: recommendedDataAppVersion() }, + scripts: { custom: "echo user-owned" }, + }); const child = Bun.spawn([process.execPath, "install", "--frozen-lockfile", "--ignore-scripts"], { cwd: directory, stdout: "pipe", stderr: "pipe", }); - const stderr = await new Response(child.stderr).text(); - expect(await child.exited, stderr).toBe(0); - expect(await readFile(join(directory, "src/App.tsx"), "utf8")).toBe(originalApp); - delete pkg.dependencies["@floating-ui/core"]; - files["package.json"] = JSON.stringify(pkg); - expect(await upgradeApp(directory, { runtimeFiles: files })).toBe(true); - expect(await installedRuntimeIntegrity(directory)).toEqual(runtimeIntegrity(files)); -}, 30_000); - -test("failed dependency resolution rolls back runtime, added peer, and lockfile", async () => { - const { directory, files } = await fixture(); - const app = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); - delete app.dependencies["react-dom"]; - await writeFile(join(directory, "package.json"), JSON.stringify(app)); - files["src/core/format.ts"] += "\n// changed\n"; + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + expect(code, `${stdout}\n${stderr}`).toBe(0); + expect(await upgradeApp(directory)).toBe(false); +}, 60_000); + +test("failed resolution restores all migration edits and leaves the legacy runtime intact", async () => { + const directory = await fixture(); const before = await snapshot(directory); - const failure = await upgradeApp(directory, { - runtimeFiles: files, - async resolveLock(path) { - expect( - JSON.parse(await readFile(join(path, "package.json"), "utf8")).dependencies["react-dom"], - ).toBe("19.3.0"); - await writeFile(join(path, "bun.lock"), "partial lock"); - throw new Error("resolution failed"); - }, - }).catch((error: unknown) => error); - expect(failure).toMatchObject({ message: "resolution failed" }); + await expectUpgradeFailure( + () => + upgradeApp(directory, { + async resolveLock(path) { + await writeFile(join(path, "bun.lock"), "partial lock"); + throw new Error("resolution failed"); + }, + }), + "resolution failed", + ); expect(await snapshot(directory)).toEqual(before); }); -test("an incompatible new peer requirement fails before mutation", async () => { - const { directory, files } = await fixture(); - const pkg = JSON.parse(files["package.json"]!); - pkg.peerDependencies.react = ">=99"; - files["package.json"] = JSON.stringify(pkg); +test("failure after runtime removal restores the runtime, manifest, lock and sources", async () => { + const directory = await fixture(); const before = await snapshot(directory); - const failure = await upgradeApp(directory, { runtimeFiles: files }).catch( - (error: unknown) => error, + await expectUpgradeFailure( + () => + upgradeApp(directory, { + resolveLock, + validateMigration, + afterApply(path) { + if (path === join(directory, legacyRuntimePath)) throw new Error("write failure"); + }, + }), + "write failure", ); - expect(failure).toMatchObject({ message: expect.stringContaining("Runtime needs react@>=99") }); expect(await snapshot(directory)).toEqual(before); }); -test("removed runtime files trigger an upgrade even without a version change", async () => { - const { directory, files } = await fixture(); - delete files["src/assets.d.ts"]; - expect(await upgradeApp(directory, { runtimeFiles: files })).toBe(true); - expect(await Bun.file(join(directory, ".altertable/runtime/src/assets.d.ts")).exists()).toBe( - false, +test.each(["modified", "added", "symlink"])( + "legacy migration refuses %s runtime files before mutation", + async (kind) => { + const directory = await fixture(); + const runtime = join(directory, legacyRuntimePath); + if (kind === "modified") await writeFile(join(runtime, "src/server.ts"), "custom runtime"); + else if (kind === "added") await writeFile(join(runtime, "custom.ts"), "custom runtime"); + else await symlink(join(directory, "package.json"), join(runtime, "custom.ts")); + const before = await snapshot(directory); + await expectUpgradeFailure( + () => upgradeApp(directory, { resolveLock, validateMigration }), + kind === "modified" ? "was modified" : kind === "added" ? "unmanaged file" : "symlink", + ); + expect(await snapshot(directory)).toEqual(before); + }, +); + +test("mixed and aliased server imports migrate without changing portable handler imports", async () => { + const directory = await fixture(); + await writeFile( + join(directory, "src/server.ts"), + 'import { createDataHandler, serveLocalApp as serve, localLakehouse } from "@altertable/data-app/server"; // Portable handler and local adapter\nconsole.log(createDataHandler, serve, localLakehouse);\n', ); + await upgradeApp(directory, { resolveLock, validateMigration }); + const source = await readFile(join(directory, "src/server.ts"), "utf8"); + expect(source).toContain('import { createDataHandler } from "@altertable/data-app/server"'); + expect(source).toContain( + 'import { serveLocalApp as serve, localLakehouse } from "@altertable/data-app/server/bun"', + ); + expect(source).toContain("// Portable handler and local adapter\nimport { createDataHandler }"); + expect(source).toContain("console.log(createDataHandler, serve, localLakehouse)"); }); -test("legacy generated apps migrate internal paths while keeping app source and public exports", async () => { - const { directory, files } = await fixture(); - const legacy = Object.fromEntries( - Object.entries(files).map(([name, content]) => [name.replace(/^src\//, ""), content]), - ); - const pkg = JSON.parse(legacy["package.json"]!); - pkg.version = "0.57.0"; - pkg.exports = Object.fromEntries( - Object.entries(pkg.exports).map(([name, path]) => [name, String(path).replace("./src/", "./")]), - ); - legacy["package.json"] = JSON.stringify(pkg); - await rm(join(directory, ".altertable/runtime"), { recursive: true }); - for (const [name, content] of Object.entries(legacy)) { - await mkdir(dirname(join(directory, ".altertable/runtime", name)), { recursive: true }); - await writeFile(join(directory, ".altertable/runtime", name), content); - } +test("unsupported namespace imports stop before any writes", async () => { + const directory = await fixture(); await writeFile( - join(directory, ".altertable/runtime/integrity.json"), - JSON.stringify(runtimeIntegrity(legacy)), + join(directory, "src/server.ts"), + 'import * as server from "@altertable/data-app/server";\nserver.serveLocalApp({});\n', + ); + const before = await snapshot(directory); + await expectUpgradeFailure( + () => upgradeApp(directory, { resolveLock, validateMigration }), + "Cannot safely migrate server imports", + ); + expect(await snapshot(directory)).toEqual(before); +}); + +test("incompatible peers and invalid locks fail before mutation", async () => { + const directory = await fixture(); + const pkg = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); + pkg.dependencies.react = "^18.0.0"; + await writeFile(join(directory, "package.json"), JSON.stringify(pkg)); + const before = await snapshot(directory); + await expectUpgradeFailure( + () => upgradeApp(directory, { resolveLock, validateMigration }), + "Data app package needs react@", ); - const appSource = await readFile(join(directory, "src/App.tsx"), "utf8"); - expect(await upgradeApp(directory, { runtimeFiles: files })).toBe(true); - expect(await Bun.file(join(directory, ".altertable/runtime/server.ts")).exists()).toBe(false); - expect(await Bun.file(join(directory, ".altertable/runtime/src/server/index.ts")).exists()).toBe( - true, + expect(await snapshot(directory)).toEqual(before); + await writeFile(join(directory, "bun.lock"), "invalid"); + const invalid = await snapshot(directory); + await expectUpgradeFailure( + () => upgradeApp(directory, { resolveLock, validateMigration }), + "valid bun.lock", ); - expect(await readFile(join(directory, "src/App.tsx"), "utf8")).toBe(appSource); - expect(await installedRuntimeIntegrity(directory)).toEqual(runtimeIntegrity(files)); + expect(await snapshot(directory)).toEqual(invalid); }); -test("missing peers are installed from starter defaults without replacing app customizations", async () => { - const { directory, files } = await fixture(); - const app = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); - delete app.dependencies["react-dom"]; - app.scripts.custom = "echo user-owned"; - await writeFile(join(directory, "package.json"), JSON.stringify(app)); - expect(await upgradeApp(directory, { runtimeFiles: files })).toBe(true); +test("missing peers use starter defaults while preserving custom scripts", async () => { + const directory = await fixture(); + const pkg = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); + delete pkg.dependencies["react-dom"]; + await writeFile(join(directory, "package.json"), JSON.stringify(pkg)); + await upgradeApp(directory, { resolveLock, validateMigration }); expect(JSON.parse(await readFile(join(directory, "package.json"), "utf8"))).toMatchObject({ dependencies: { "react-dom": "19.3.0" }, scripts: { custom: "echo user-owned" }, }); -}, 30_000); - -test.each(["^19.0.0", ">=19"])("compatible locked peer range %s is preserved", async (range) => { - const { directory, files } = await fixture(); - const app = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); - app.dependencies.react = range; - const source = JSON.stringify(app); - await writeFile(join(directory, "package.json"), source); - files["src/core/format.ts"] += "\n// source change\n"; - expect(await upgradeApp(directory, { runtimeFiles: files })).toBe(true); - expect(await readFile(join(directory, "package.json"), "utf8")).toBe(source); }); -test("unchanged peer requirements still reject incompatible app dependencies", async () => { - const { directory, files } = await fixture(); - const app = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); - app.dependencies.react = "^18.0.0"; - await writeFile(join(directory, "package.json"), JSON.stringify(app)); - files["src/core/format.ts"] += "\n// source change\n"; +test("registry upgrades pin a compatible range and preserve unrelated dependencies and app files", async () => { + const directory = await fixture(false); + const pkg = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); + pkg.dependencies[dataAppPackage] = "^0.59.1"; + pkg.dependencies.yaml = "2.9.0"; + pkg.scripts.custom = "echo user-owned"; + await writeFile(join(directory, "package.json"), JSON.stringify(pkg)); + const { packages } = readAppLock(await readFile(join(directory, "bun.lock"), "utf8"), directory); + + await writeFile( + join(directory, "bun.lock"), + JSON.stringify({ + lockfileVersion: 2, + configVersion: 1, + workspaces: { "": { dependencies: pkg.dependencies } }, + packages, + }), + ); + const original = await readFile(join(directory, "src/App.tsx"), "utf8"); + await upgradeApp(directory); + expect(JSON.parse(await readFile(join(directory, "package.json"), "utf8"))).toMatchObject({ + dependencies: { yaml: "2.9.0", [dataAppPackage]: recommendedDataAppVersion() }, + scripts: { custom: "echo user-owned" }, + }); + expect(await readFile(join(directory, "src/App.tsx"), "utf8")).toBe(original); +}, 60_000); + +test("a newer installed package is never downgraded", async () => { + const directory = await fixture(false); + const pkg = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); + pkg.dependencies[dataAppPackage] = "9.0.0"; + await writeFile(join(directory, "package.json"), JSON.stringify(pkg)); + const { packages } = readAppLock(await readFile(join(directory, "bun.lock"), "utf8"), directory); + packages[dataAppPackage] = [`${dataAppPackage}@9.0.0`, "", {}, "integrity"]; + await writeFile( + join(directory, "bun.lock"), + JSON.stringify({ + lockfileVersion: 2, + configVersion: 1, + workspaces: { "": { dependencies: pkg.dependencies } }, + packages, + }), + ); const before = await snapshot(directory); - const failure = await upgradeApp(directory, { runtimeFiles: files }).catch( - (error: unknown) => error, + expect(await upgradeApp(directory)).toBe(false); + expect(await snapshot(directory)).toEqual(before); +}); + +test.each([ + 'import { StorySection } from "@altertable/data-app/react";', + 'import { PlayStory as Present } from "@altertable/data-app/react";', + "export const view = ;", + "export const step = context.storyStep({});", +])("legacy story APIs receive migration guidance before mutation: %s", async (source) => { + const directory = await fixture(); + await writeFile(join(directory, "src/App.tsx"), source); + const before = await snapshot(directory); + await expectUpgradeFailure( + () => upgradeApp(directory, { resolveLock, validateMigration }), + "Migrate story authoring", ); - expect(failure).toMatchObject({ message: expect.stringContaining("Runtime needs react@>=19") }); + expect(await readFile(join(directory, "src/App.tsx"), "utf8")).toBe(source); expect(await snapshot(directory)).toEqual(before); }); + +test("an incompatible namespace component rolls back after actual consumer validation", async () => { + const directory = await fixture(); + await writeFile( + join(directory, "src/custom.tsx"), + 'import * as UI from "@altertable/data-app/react"; export function Custom() { return Chart

} />; }\n', + ); + const install = Bun.spawn([process.execPath, "install", "--ignore-scripts"], { + cwd: directory, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(install.stdout).text(), + new Response(install.stderr).text(), + install.exited, + ]); + expect(code, `${stdout}\n${stderr}`).toBe(0); + await writeFile(join(directory, "node_modules/custom-marker.txt"), "original install"); + const installedPackage = await readFile( + join(directory, "node_modules/@altertable/data-app/package.json"), + "utf8", + ); + const before = await snapshot(directory); + await expectUpgradeFailure(() => upgradeApp(directory), "The app needs source changes"); + expect(await readFile(join(directory, "node_modules/custom-marker.txt"), "utf8")).toBe( + "original install", + ); + expect( + await readFile(join(directory, "node_modules/@altertable/data-app/package.json"), "utf8"), + ).toBe(installedPackage); + expect(await snapshot(directory)).toEqual(before); + expect(await readFile(join(directory, "src/custom.tsx"), "utf8")).toContain("UI.StorySection"); +}, 60_000); diff --git a/cli/src/commands/app/upgrade.ts b/cli/src/commands/app/upgrade.ts index a8205458..641445b1 100644 --- a/cli/src/commands/app/upgrade.ts +++ b/cli/src/commands/app/upgrade.ts @@ -1,29 +1,39 @@ -import { access, cp, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { access, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { defineCommand } from "@/lib/command.ts"; import { ConfigurationError } from "@/lib/errors.ts"; import { dataAppPayload } from "@/commands/app/lib/payload.ts"; import { copyProcessEnv } from "@/lib/env.ts"; -import { isRecord } from "@/lib/object.ts"; -import { appDirectory } from "@/commands/app/lib/run.ts"; +import { appDirectory, requireAppScripts, runAppCommand } from "@/commands/app/lib/run.ts"; +import { checkAppProject } from "@/commands/app/check.ts"; +import { validateLegacyRuntime } from "@/commands/app/lib/legacy-runtime.ts"; +import { legacyAppEdits } from "@/commands/app/lib/legacy-app.ts"; import { - currentRuntimeIntegrity, - installedRuntimeIntegrity, - runtimeFiles, - runtimePath, -} from "@/commands/app/lib/runtime.ts"; + dataAppPackage, + legacyRuntimePath, + lockedVersion, + readAppLock, + readAppPackage, + recommendedDataAppPeers, + recommendedDataAppVersion, +} from "@/commands/app/lib/package.ts"; export const appUpgradeCommand = defineCommand({ metadata: { name: "upgrade", - description: "Update an unmodified data app runtime to the CLI's current version.", + description: + "Update the data app package to the CLI's tested version, migrating vendored apps.", examples: ["altertable app upgrade", "altertable app upgrade --dir ./my-app"], }, args: { dir: { type: "string", description: "App directory (default: current directory)." } }, async run({ args, sink }) { const directory = appDirectory(args.dir); const upgraded = await upgradeApp(directory); - const version = currentRuntimeIntegrity().version; + const { packages } = readAppLock( + await readFile(join(directory, "bun.lock"), "utf8"), + directory, + ); + const version = lockedVersion(packages, dataAppPackage)!; if (sink.json) { sink.writeJson({ directory, @@ -39,8 +49,8 @@ export const appUpgradeCommand = defineCommand({ } else { sink.writeHuman( upgraded - ? `Updated data app runtime to ${version}. Run \`altertable app check\`, then restart any running \`altertable app dev\` server.` - : `Data app runtime ${version} is already current.`, + ? `Updated data app package to ${version}. Run \`altertable app check\`, then restart any running \`altertable app dev\` server.` + : `Data app package ${version} is already current.`, ); } }, @@ -48,203 +58,118 @@ export const appUpgradeCommand = defineCommand({ type UpgradeOptions = { afterApply?: (path: string) => void | Promise; - runtimeFiles?: Record; resolveLock?: (directory: string) => Promise; + validateMigration?: (directory: string) => Promise; }; type Applied = { destination: string; backup: string | null }; -function parseJsonc(source: string): unknown { - let withoutComments = ""; - let inString = false; - let escaped = false; - for (let index = 0; index < source.length; index++) { - const character = source[index]!; - const next = source[index + 1]; - if (inString) { - withoutComments += character; - if (escaped) escaped = false; - else if (character === "\\") escaped = true; - else if (character === '"') inString = false; - } else if (character === '"') { - inString = true; - withoutComments += character; - } else if (character === "/" && next === "/") { - while (index < source.length && source[index] !== "\n") index++; - withoutComments += "\n"; - } else if (character === "/" && next === "*") { - index += 2; - while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) - index++; - if (index >= source.length) throw new Error("Unterminated JSONC comment"); - index++; - withoutComments += " "; - } else { - withoutComments += character; - } - } - if (inString) throw new Error("Unterminated JSONC string"); - let withoutTrailingCommas = ""; - inString = false; - escaped = false; - for (let index = 0; index < withoutComments.length; index++) { - const character = withoutComments[index]!; - if (inString) { - withoutTrailingCommas += character; - if (escaped) escaped = false; - else if (character === "\\") escaped = true; - else if (character === '"') inString = false; - } else if (character === '"') { - inString = true; - withoutTrailingCommas += character; - } else if (character === "," && /^[\s]*[}\]]/.test(withoutComments.slice(index + 1))) { - continue; - } else { - withoutTrailingCommas += character; - } - } - return JSON.parse(withoutTrailingCommas) as unknown; -} - -async function exists(path: string): Promise { - try { - await access(path); - return true; - } catch { - return false; - } -} - -function readLock(lock: string, directory: string): Record { - let value: unknown; - try { - value = parseJsonc(lock); - } catch { - throw new ConfigurationError(`Cannot read a valid bun.lock in ${directory}.`); - } - const workspaces = isRecord(value) && value.workspaces; - const root = isRecord(workspaces) && workspaces[""]; - const dependencies = isRecord(root) && root.dependencies; - if ( - !isRecord(value) || - !isRecord(value.packages) || - !isRecord(dependencies) || - !Object.values(dependencies).some((reference) => reference === `file:${runtimePath}`) - ) { - throw new ConfigurationError("bun.lock does not reference the generated runtime."); - } - return value.packages; -} - -/** Upgrade from validated inputs. Staged replacements are swapped into place and rolled back on failure. */ +/** Manifest, lockfile, migration edits, and runtime removal share one rollback boundary. */ export async function upgradeApp( directory: string, options: UpgradeOptions = {}, ): Promise { - const files: Record = options.runtimeFiles ?? runtimeFiles; - const installed = await installedRuntimeIntegrity(directory); - const current = currentRuntimeIntegrity(files); - const runtimeUnchanged = - installed.version === current.version && - Object.keys(installed.sha256).length === Object.keys(current.sha256).length && - Object.entries(current.sha256).every(([name, checksum]) => installed.sha256[name] === checksum); - - const lockPath = join(directory, "bun.lock"); + const version = recommendedDataAppVersion(); + const { manifest } = await readAppPackage(directory); + const reference = manifest.dependencies?.[dataAppPackage]; + const legacy = reference === `file:${legacyRuntimePath}`; + if (!reference || (!legacy && !/^[\d~^<>=*]/.test(reference))) { + throw new ConfigurationError( + `package.json must reference the published ${dataAppPackage} package or the generated vendored runtime.`, + ); + } let lock: string; try { - lock = await readFile(lockPath, "utf8"); + lock = await readFile(join(directory, "bun.lock"), "utf8"); } catch { throw new ConfigurationError(`Cannot read a valid bun.lock in ${directory}.`); } - const lockedPackages = readLock(lock, directory); - const packagePath = join(directory, "package.json"); - const packageSource = await readFile(packagePath, "utf8"); - const appPackage = JSON.parse(packageSource) as PackageManifest; - if (appPackage.dependencies?.["@altertable/data-app"] !== `file:${runtimePath}`) { - throw new ConfigurationError("package.json does not reference the generated runtime."); + const { packages } = readAppLock(lock, directory, reference); + const installedVersion = lockedVersion(packages, dataAppPackage); + if (!legacy && (!installedVersion || !Bun.semver.satisfies(installedVersion, reference))) { + throw new ConfigurationError( + "bun.lock does not resolve the declared data app package. Run bun install before upgrading.", + ); } - const previousPackage = JSON.parse( - await readFile(join(directory, runtimePath, "package.json"), "utf8"), - ) as PackageManifest; - const nextPackage = JSON.parse(files["package.json"]!) as PackageManifest; - const defaults = JSON.parse(dataAppPayload.starter["package.json"]!) as PackageManifest; - let packageChanged = false; - for (const [name, range] of Object.entries(nextPackage.peerDependencies ?? {})) { - const existing = appPackage.dependencies?.[name] ?? appPackage.devDependencies?.[name]; - const locked = lockedPackages[name]; - const locator = Array.isArray(locked) && typeof locked[0] === "string" ? locked[0] : ""; - const resolvedVersion = locator.startsWith(`${name}@`) ? locator.slice(name.length + 1) : ""; - // A lockfile version proves compatibility for user-authored ranges such as ^19. + if (legacy) await validateLegacyRuntime(directory); + // A newer package installed intentionally must never be downgraded by an older CLI. + if (!legacy && installedVersion && Bun.semver.order(installedVersion, version) > 0) return false; + const defaults = JSON.parse(dataAppPayload.starter["package.json"]!).dependencies as Record< + string, + string + >; + let packageChanged = reference !== version; + for (const [name, range] of Object.entries(recommendedDataAppPeers())) { + const existing = manifest.dependencies?.[name] ?? manifest.devDependencies?.[name]; + const resolved = lockedVersion(packages, name); if ( existing && - (satisfies(existing, range) || - (satisfies(resolvedVersion, existing) && satisfies(resolvedVersion, range))) + resolved && + Bun.semver.satisfies(resolved, existing) && + Bun.semver.satisfies(resolved, range) ) continue; if (existing) throw new ConfigurationError( - `Runtime needs ${name}@${range}; app declares ${existing}. Update package.json before upgrading.`, + `Data app package needs ${name}@${range}; app declares ${existing}. Update package.json and bun.lock before upgrading.`, ); - const version = defaults.dependencies?.[name]; - if (!version || !satisfies(version, range)) - throw new ConfigurationError( - `Runtime needs ${name}@${range}. Add a compatible dependency to package.json before upgrading.`, - ); - appPackage.dependencies ??= {}; - appPackage.dependencies[name] = version; + const fallback = defaults[name]; + if (!fallback || !Bun.semver.satisfies(fallback, range)) + throw new ConfigurationError(`Add ${name}@${range} before upgrading.`); + manifest.dependencies![name] = fallback; packageChanged = true; } - if (runtimeUnchanged && !packageChanged) return false; - const resolveDependencies = - packageChanged || - JSON.stringify(previousPackage.dependencies) !== JSON.stringify(nextPackage.dependencies) || - JSON.stringify(previousPackage.peerDependencies) !== - JSON.stringify(nextPackage.peerDependencies); + if (!legacy && !packageChanged) return false; + const edits = legacy ? await legacyAppEdits(directory) : new Map(); + manifest.dependencies![dataAppPackage] = version; + const nextPackage = `${JSON.stringify(manifest, null, 2)}\n`; + edits.set("package.json", nextPackage); + edits.set("bun.lock", lock); + const managedDirectory = join(directory, ".altertable"); + if ((await exists(managedDirectory)) && !(await lstat(managedDirectory)).isDirectory()) { + throw new ConfigurationError(".altertable must be a real directory before upgrading."); + } + await mkdir(managedDirectory, { recursive: true }); const transaction = await mkdtemp(join(directory, ".altertable", ".upgrade-")); const applied: Applied[] = []; let preserveBackup = false; - try { - const target = join(directory, runtimePath); - const stagedRuntime = join(transaction, "stage-runtime"); - if (await exists(target)) await cp(target, stagedRuntime, { recursive: true }); - else await mkdir(stagedRuntime); - for (const [name, content] of Object.entries(files)) { - const path = join(stagedRuntime, name); - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, content); - } - for (const name of Object.keys(installed.sha256)) { - if (!(name in files)) await rm(join(stagedRuntime, name)); + async function apply(name: string, content?: string): Promise { + const destination = join(directory, name); + const backup = join(transaction, "backup", name); + const hadOriginal = await exists(destination); + if (hadOriginal) { + await mkdir(dirname(backup), { recursive: true }); + await rename(destination, backup); } - await writeFile(join(stagedRuntime, "integrity.json"), `${JSON.stringify(current, null, 2)}\n`); - async function apply(staged: string, destination: string, name: string): Promise { - const backup = join(transaction, "backup", name); - const hadOriginal = await exists(destination); - if (hadOriginal) { - await mkdir(dirname(backup), { recursive: true }); - await rename(destination, backup); - } - applied.push({ destination, backup: hadOriginal ? backup : null }); - await rename(staged, destination); - await options.afterApply?.(destination); - } - await apply(stagedRuntime, target, "runtime"); - if (resolveDependencies) { - const stagedPackage = join(transaction, "package.json"); - await writeFile( - stagedPackage, - packageChanged ? `${JSON.stringify(appPackage, null, 2)}\n` : packageSource, - ); - await apply(stagedPackage, packagePath, "package.json"); - const stagedLock = join(transaction, "bun.lock"); - await writeFile(stagedLock, lock); - await apply(stagedLock, lockPath, "bun.lock"); - await (options.resolveLock ?? resolveLockfile)(directory); - readLock(await readFile(lockPath, "utf8"), directory); - // Bun update may rewrite the root manifest; preserve all app-owned fields and formatting. - await writeFile( - packagePath, - packageChanged ? `${JSON.stringify(appPackage, null, 2)}\n` : packageSource, + applied.push({ destination, backup: hadOriginal ? backup : null }); + if (content !== undefined) await writeFile(destination, content); + await options.afterApply?.(destination); + } + try { + for (const [name, content] of edits) await apply(name, content); + await (options.resolveLock ?? resolveLockfile)(directory); + const resolved = readAppLock( + await readFile(join(directory, "bun.lock"), "utf8"), + directory, + version, + ); + if (lockedVersion(resolved.packages, dataAppPackage) !== version) + throw new ConfigurationError( + "Dependency resolution did not lock the tested data app version.", ); + for (const [name, range] of Object.entries(recommendedDataAppPeers())) { + const peerVersion = lockedVersion(resolved.packages, name); + if (!peerVersion || !Bun.semver.satisfies(peerVersion, range)) + throw new ConfigurationError( + `Resolved ${name} is incompatible with data app's ${range} peer requirement.`, + ); + } + // Bun may rewrite the root manifest; preserve all unrelated app-owned fields. + await writeFile(join(directory, "package.json"), nextPackage); + if (legacy) { + // Retain the original installation until the migrated consumer passes validation. + await apply("node_modules"); + await (options.validateMigration ?? validateMigration)(directory); + await apply(legacyRuntimePath); } } catch (error) { const rollbackFailures: string[] = []; @@ -269,15 +194,10 @@ export async function upgradeApp( return true; } -type PackageManifest = { - dependencies?: Record; - devDependencies?: Record; - peerDependencies?: Record; -}; - -function satisfies(version: string, range: string): boolean { +async function exists(path: string): Promise { try { - return Bun.semver.satisfies(version, range); + await access(path); + return true; } catch { return false; } @@ -305,3 +225,31 @@ async function resolveLockfile(directory: string): Promise { `Could not resolve runtime dependencies; upgrade rolled back.\n${stderr || stdout}`, ); } + +/** Prove authored code can consume the new exports before deleting its runtime copy. */ +async function validateMigration(directory: string): Promise { + requireAppScripts(directory, ["typecheck"]); + const env = copyProcessEnv(); + for (const key of Object.keys(env)) if (key.startsWith("ALTERTABLE_")) delete env[key]; + const child = Bun.spawn([process.execPath, "install", "--frozen-lockfile", "--ignore-scripts"], { + cwd: directory, + env: { ...env, BUN_BE_BUN: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + if (code !== 0) + throw new ConfigurationError( + `Could not install the migrated app; upgrade rolled back.\n${stderr || stdout}`, + ); + if ((await runAppCommand("typecheck", directory)) !== 0) { + throw new ConfigurationError( + "The app needs source changes for the published package; upgrade rolled back. See https://github.com/altertable-ai/data-app/blob/main/docs/react.md#migration-from-the-earlier-runtime.", + ); + } + await checkAppProject(directory); +} diff --git a/data-app/AGENTS.md b/data-app/AGENTS.md index 7fe7fef4..0cd1e3b4 100644 --- a/data-app/AGENTS.md +++ b/data-app/AGENTS.md @@ -2,15 +2,13 @@ | Change | Source and guidance | Focused check | | --- | --- | --- | -| Runtime contract, transport, or formatting | `runtime/src/`; [API map](runtime/README.md#entry-points) | Runtime typecheck and `runtime/tests/` | -| UI component or behavior | [UI map](runtime/README.md#find-ui-by-task); styles beside components | Runtime checks; relevant browser scenarios in `tests/` | -| Generated app defaults or author guidance | `starter/`; [app authoring router](starter/AGENTS.md) | Starter checks and CLI app creation tests | -| Copied files or runtime upgrades | `../cli/src/commands/app/lib/distribution.ts`, `../cli/src/commands/app/upgrade.ts` | Distribution and upgrade tests; packaged-app smoke | - -See [development commands](README.md#develop). Run `bun run --cwd cli data-app:check` from the repository root for source checks. Run browser scenarios from `data-app/tests/` with `bun run test`. - -Keep `runtime/src/core/`, `client/`, and `server/` free of React and UI imports; the boundary test enforces this. `runtime/src/react/index.ts` exports the public React API; implementations live in focused modules beside `react/ui/`. Keep component styles beside their source. Source-specific SQL and metric definitions belong in apps. - -JSDoc should explain constraints, ownership, units, security boundaries, or surprising behavior. Let names and types describe obvious props and functions. Put task routing in the API map and app guides. - -Canonical `runtime/` is tracked. The repository starter's `.altertable/runtime/` is ignored and recreated by setup; generated user apps commit their vendored runtime. Runtime `package.json` lists distributed files, while the CLI distribution manifest lists starter files. Keep routing links valid in the generated artifact. +| Package contracts, transport, or UI | [Data App repository](https://github.com/altertable-ai/data-app) | Package repository checks | +| Generated app defaults or author guidance | `starter/`; [app authoring router](starter/AGENTS.md) | Starter checks and CLI creation tests | +| Scaffold distribution or package upgrades | `../cli/src/commands/app/lib/distribution.ts`, `../cli/src/commands/app/upgrade.ts` | Distribution, migration, and packaged-app tests | +| Consumer integration or browser behavior | `tests/`, `starter/fixtures/` | Browser tests against the published package | + +See [development commands](README.md#develop). Keep SQL and metric definitions in app-owned source. +Import only public package exports; local Bun serving uses `/server/bun`, and each browser entry +imports `/react/styles.css`. Keep starter and browser-test package pins aligned, commit their +lockfiles, and preserve documentation links to installed package docs. Do not copy runtime source +into this repository or generated apps. diff --git a/data-app/README.md b/data-app/README.md index 87cc3bca..e7e1dc03 100644 --- a/data-app/README.md +++ b/data-app/README.md @@ -1,10 +1,9 @@ # Data app development -`runtime/` is the private `@altertable/data-app` package. `starter/` is the actual getting-started application. `tests/` runs browser scenarios against the starter and its separate component fixtures. - -The [runtime API map](runtime/README.md) routes readers to public entries and UI source. The [contributor router](AGENTS.md) identifies source and checks for each change. - -The package separates React-free `core/`, `client/`, and `server/` source from `react/`, which owns hooks, the `DataApp` shell, and UI. Generated apps keep their question, SQL, validation, exploration context, and view in `src/`. +`starter/` is the application copied by `altertable app create`. It pins the published +[`@altertable/data-app`](https://github.com/altertable-ai/data-app) package and ships a frozen +lockfile. Package source, API documentation, unit tests, and releases belong to that repository. +`tests/` exercises the starter and component fixtures against the installed npm package. ## Develop @@ -12,49 +11,62 @@ From the repository root, using Fish: ```fish bun install --cwd cli --frozen-lockfile -bun run --cwd cli data-app:setup -bun install --cwd data-app/runtime --frozen-lockfile bun install --cwd data-app/starter --frozen-lockfile -./bin/altertable app dev --dir data-app/starter --watch-runtime +./bin/altertable app dev --dir data-app/starter ``` -Use a configured profile for live data. `--watch-runtime` watches canonical runtime source, validates the installed copy, upgrades it, and restarts the app. Edit runtime source in `runtime/src/`; the starter's `.altertable/runtime/` is generated and ignored. The starter's connection check executes a bounded query before showing Connected. +Use a configured profile for live data. The starter executes a bounded connection query before +showing Connected. App queries, data context, views, and appearance remain app-owned. ## Ownership and distribution -- `runtime/package.json` declares public exports and a `files` allowlist. Tests and development configuration live outside that list. -- `runtime/.oxlintrc.json` checks runtime React code; `starter/.oxlintrc.json` ships with each new app so `app check` enforces React Compiler, hooks, and accessibility correctness rules on app-owned source. -- Generated apps use package `#app/*` and `#config` imports. Oxlint's built-in `no-restricted-imports` rule rejects relative source imports without a custom plugin. -- `cli/src/commands/app/lib/distribution.ts` declares the starter copy allowlist. `src/`, app configuration, lockfile, and authoring docs ship. Browser fixtures, tests, dependencies, and build outputs do not. -- `createAppFiles` changes JSON identity fields and the lockfile root name. Application code reads `app.json`; source code has no template tokens. -- `cli/scripts/package-data-app.ts` supplies the Bun build plugin. It replaces the source payload loader with literal file data. The npm bundle embeds this payload; native releases compile that same bundle. Installed CLIs never read this repository to create an app. -- Generated apps commit `.altertable/runtime/` because it is a required `file:` dependency; only this repository's starter copy is ignored. Users own their generated `src/`, `app.json`, package manifest, and docs. The CLI owns `.altertable/runtime/` and records its source checksums. Public import paths remain `@altertable/data-app/...`. +The CLI embeds only the starter allowlist in `cli/src/commands/app/lib/distribution.ts`. +Both npm and native CLI builds can scaffold an app without reading this checkout or accessing +the network. Installing the generated app needs registry access or a populated Bun cache. +Generated projects commit their source, package manifest, and lockfile; they do not vendor runtime +source. Public imports use `@altertable/data-app/...`. -The runtime owns its implementation dependencies. The starter owns React, ReactDOM, and its authoring tools. When runtime dependencies change, refresh the starter lockfile after setup: +The starter owns React, ReactDOM, and its authoring tools. The package owns its implementation +dependencies. Its stylesheet is imported explicitly by the browser entry. The app's `AGENTS.md` +points to the installed package guide and docs; dependency guides are not assumed to load automatically. -```fish -cd data-app/starter -bun update @altertable/data-app --lockfile-only --ignore-scripts -``` - -Commit both project lockfiles when their respective dependencies change. Runtime code changes alone need no lockfile update. Keep exported API changes compatible with existing apps, or explain the required application migration explicitly. +To adopt a package release, update its exact pin in both `starter/package.json` and +`tests/package.json`, regenerate both lockfiles, and run the checks below. Never resolve `latest` +during app creation. Develop package changes in the package repository; the CLI has no runtime +source watcher. ## Upgrades -`app upgrade` validates installed checksums before replacing managed files, including removal of obsolete runtime files. It preserves app source. Dependency changes trigger a targeted Bun lockfile update without installing packages or running lifecycle scripts. Missing peers can be seeded from the starter's pinned dependencies; incompatible new peer requirements stop with an actionable error. A failed update restores runtime, package manifest, and lockfile. Dependency resolution can require the network or a populated Bun cache. Restart running previews after an upgrade. - -The package is still private and bundled with the CLI. New generated apps use `@altertable/data-app` imports; the CLI upgrades the managed runtime files without changing app-owned source. +`app upgrade` updates the app manifest and lockfile to the CLI's tested package version, +preserving unrelated dependencies and app code. A newer installed package is not downgraded. +Peer incompatibilities stop with an actionable error; missing peers are seeded from starter defaults. +Registry upgrades resolve only the lockfile. Legacy migrations also install the frozen dependencies +with lifecycle scripts disabled and run the app typecheck and contract/browser-boundary checks before +removing the managed runtime. Failures restore +the manifest, lockfile, migration edits, and the original installed dependencies. Restart running +previews after an upgrade. + +For older generated apps using `file:.altertable/runtime`, upgrade first validates all managed +checksums and refuses modified, added, or symlinked runtime files. It migrates local server helpers +to `/server/bun`, imports the public stylesheet, updates vendored documentation links, and removes +the runtime copy only after package resolution succeeds. Unsupported custom server imports or a +custom browser entry need the indicated manual edit before retrying. Older custom stories must +migrate from `StorySection`/`PlayStory` to `DataWidget`/`PresentStory` and explicit finding evidence +before upgrading; see the [package migration guide](https://github.com/altertable-ai/data-app/blob/main/docs/react.md#migration-from-the-earlier-runtime). ## Verify ```fish bun run --cwd cli data-app:check -cd data-app/tests -bun install --frozen-lockfile -bunx playwright install chromium -bun run test +bun install --cwd data-app/tests --frozen-lockfile +bun run --cwd cli data-app:test:browser +./scripts/verify.sh --quick +./scripts/verify.sh ``` -Source checks cover every runtime module and its contract, transport, search, formatting, and public component composition tests. Starter checks cover types, lint, formatting, and a production build. Browser tests cover connection states, retry, stale success, context, theme persistence, and Present navigation at desktop and phone sizes. - -CLI tests cover distribution safety, creation, upgrades, dependency resolution, and rollback. Release smoke checks generate an app outside the checkout, install with a frozen lockfile, and run `app check` using the packaged CLI. The minimum CLI runtime compatibility job checks scaffolding only; building data apps uses the repository's current Bun toolchain. +Install Playwright Chromium with `bunx playwright install chromium` from `data-app/tests/` +if needed. Browser checks cover connection and request states, theme, context, and presentation +at phone and desktop widths. CLI tests cover offline scaffolding, npm consumption, legacy migration, +peer checks, and rollback. Release smoke checks create an app outside the checkout, install its +frozen lockfile, and run `app check` with the packaged CLI. The minimum Bun compatibility job +checks scaffolding only; app builds use the repository's current toolchain. diff --git a/data-app/runtime/.oxfmtrc.json b/data-app/runtime/.oxfmtrc.json deleted file mode 100644 index e9c09693..00000000 --- a/data-app/runtime/.oxfmtrc.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "./node_modules/oxfmt/configuration_schema.json", - "ignorePatterns": ["dist/**", "bun.lock"] -} diff --git a/data-app/runtime/.oxlintrc.json b/data-app/runtime/.oxlintrc.json deleted file mode 100644 index efd27967..00000000 --- a/data-app/runtime/.oxlintrc.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["eslint", "typescript", "unicorn", "oxc", "react", "jsx-a11y"], - "categories": { - "correctness": "error" - }, - "options": { - "typeAware": true - }, - "rules": { - "react/unsupported-syntax": "error" - } -} diff --git a/data-app/runtime/README.md b/data-app/runtime/README.md deleted file mode 100644 index bfa4c7d9..00000000 --- a/data-app/runtime/README.md +++ /dev/null @@ -1,196 +0,0 @@ -# Data app runtime APIs - -Start with the task below, then read the linked types. Import through `@altertable/data-app/`; paths under `src/` are implementation details and can move during upgrades. - -`src/core/` contains shared contracts and pure data logic. `src/client/` and `src/server/` depend on core without importing React or UI code. `src/react/` owns hooks and `src/react/ui/` owns components and styles. Apps import the package entries below rather than those source folders. - -## Entry points - -| Task | Package entry | Start here | -| --- | --- | --- | -| Define bounded operations, live checks, and date ranges | `/contract` | [defineOperation, defineDateRangeContract, parsers](src/core/contract.ts) | -| Author app identity and scope | `/config` | [DataAppConfig](src/core/config.ts) | -| Mount the browser app, load data, and compose its UI | `/react` | [mountDataApp, createDataHooks, DataApp, Grid](src/react/index.ts) | -| Call operations without React | `/client` | [createDataClient](src/client/index.ts) | -| Run locally or host a server | `/server` | [serveLocalApp, localLakehouse, createDataHandler](src/server/index.ts) | -| Set brand tokens and viewer theme | `/appearance` | [parseAppearance, createThemeController](src/core/appearance.ts) | -| Format dates, numbers, ratios, currency, and plural forms | `/format` | [Formatting functions](src/core/format.ts) | - -## Find UI by task - -All of these APIs are exported from `/react`. Each component's stylesheet lives beside its implementation. - -| Task | Start here | Related APIs | -| --- | --- | --- | -| Primary request and page shell | [DataApp](src/react/ui/DataApp.tsx) | AppLayout, AppHeader, AppToolbar, AppFooter, AppScope, ThemeToggle | -| Initial connection check | [GettingStarted](src/react/ui/GettingStarted.tsx) | Pair with `connectionCheck()` from `/contract` | -| Arrange content | [Grid](src/react/ui/Grid.tsx), [Stack](src/react/ui/Stack.tsx) | StorySection | -| Show a key number | [MetricWidget](src/react/ui/MetricWidget.tsx) | ComparisonVisual | -| Show charts and collections | [VisualizationWidget](src/react/ui/VisualizationWidget.tsx), [TableWidget](src/react/ui/TableWidget.tsx) | DataTable, Ranking, Breakdown, chartColor | -| Handle a request's loading, error, and stale data | [DataSection](src/react/ui/DataSection.tsx) | DataBoundary, DataViewToast, EmptyState, StatusPanel, Skeleton | -| Show freshness and refresh | [UpdatedAt](src/react/ui/UpdatedAt.tsx), [AppToolbar](src/react/ui/AppToolbar.tsx) | RefreshRegion, LiveControl | -| Bind filters to the URL | [variables](src/react/ui/variables.ts), [DateRangePicker](src/react/ui/DateRangePicker.tsx) | Combobox, PeriodSummary, Tabs, useViewTab | -| Search a loaded collection | [searchItems](src/react/ui/searchItems.ts), [SearchMatch](src/react/ui/SearchMatch.tsx) | SearchField | -| Explain context, glossary, and queries | [AboutData](src/react/ui/AboutData.tsx), [DataContext](src/react/ui/data-context.ts) | [GlossaryDefinition](src/react/ui/GlossaryDefinition.tsx), GlossaryExplanation, [defineDataIdentifiers](src/react/ui/data-identifiers.tsx) | -| Present loaded findings | [PlayStory](src/react/ui/PlayStory.tsx) | StoryStep | -| Build custom controls and overlays | [Button](src/react/ui/Button.tsx), [Sheet](src/react/ui/Sheet.tsx) | IconButton, Tooltip, HelpPopover, Kbd | - -## Contracts - -| Definition | Runtime owns | -| --- | --- | -| `defineOperation` | Input/output validation, check inputs, query limits and cancellation; `query(name, sql)` accepts registered names and records executed evidence. | -| `defineDataView` | URL variables, operation input, emptiness and the primary date binding. `useView` connects the result and controls to `DataApp`. | -| `DataApp` | Header, variable bar, refresh state, stale-result notice and dimming, default inspection empty states. | -| `view.content` | One loading/ready layout. `result.select` never evaluates loading data; `result.metric` binds comparisons to the displayed input. | -| `context.metric` | Label, numeric format, glossary evidence and optional direction of improvement. | -| `WidgetViewTabs` | Valid, unique selection IDs and a required empty state per tab. | - -SQL and business definitions belong to the app. Hosted adapters authorize every request; local development uses the CLI proxy. Browser/server boundaries and managed runtime integrity are checked by `app check`. SQL disclosure also requires server permission. - -A measured zero and unavailable data have different meanings. Metric readings use `null` for an unavailable previous value. The app defines whether a result is empty. `Breakdown` shows parts of a total; `Ranking` scales against its largest value. Percent formats accept ratios. - -## Ownership - -In a generated app, commit `.altertable/runtime/`, including `integrity.json`, with `package.json` and `bun.lock`. The package is a vendored `file:` dependency required by a fresh clone. Read these files to discover APIs; keep application customizations in the app's `src/`. `altertable app upgrade` replaces unmodified runtime files and verifies their checksums. - -In the CLI repository, edit the canonical `data-app/runtime/` package. Its sibling starter's `.altertable/runtime/` copy is ignored and recreated by `data-app:setup`. - -## Bind a view - -```tsx -import { createDataClient } from "@altertable/data-app/client"; -import { createDataHooks, dateRangeVariable, DataApp, Grid, MetricWidget, VisualizationWidget, Ranking } from "@altertable/data-app/react"; -import type { operations } from "#app/operations.ts"; -import { calendar } from "#app/contracts.ts"; -import { dataContext, actions } from "#app/data-context.tsx"; -import config from "#config"; - -const period = dateRangeVariable({ - key: "period", contract: calendar, comparison: true, - defaultValue: { kind: "preset", id: "last-30" }, -}); -const { defineDataView, useView } = createDataHooks(createDataClient()); -const activityView = defineDataView({ - operation: "activity", - variables: { period }, - input: ({ period }) => period, - date: { variable: "period", input: (input) => input }, - isEmpty: (data) => data.features.length === 0, - empty: { title: "No activity in this range" }, -}); -const featureEvidence = dataContext.evidence({ - id: "feature-use", queryNames: [dataContext.queryNames.activity], -}); -const content = activityView.content((result) => ( - - ({ - current: data.count, previous: data.previousCount, - }))} /> - data.features)} - isEmpty={(features) => features.length === 0} - empty={{ title: "No features" }} - skeleton={{ variant: "ranking", rows: 6 }} - > - {(features) => } - - -)); -function App() { - const activity = useView(activityView); - return ; -} -``` - -The `date` binding identifies the controlling variable and extracts its range from the operation input. Nested inputs use, for example, `input: (input) => input.period`. The runtime rejects mappings that silently change the selected range or comparison. Non-date views supply `describeInput`; date views can override it when other inputs also need describing. - -The shared calendar lives in a browser-safe module: - -```ts -import { defineDateRangeContract } from "@altertable/data-app/contract"; -export const calendar = defineDateRangeContract({ - minDate: "2026-01-01", maxRangeDays: 90, timeZone: "UTC", -}); -// Server operation: input: calendar.parseRequest -``` - -`useView` generates controls for date, text and fixed-option select variables; custom controls use `result.variables.bind(name)`. `input` chooses which variables reach the operation, so local search can stay local. The callback in `view.content` receives the displayed result, including its original input during refreshes and failures. Hooks belong in the enclosing component. - -`TableWidget` also accepts `reading={result.select((data) => data.rows)}` and optional `skeletonRows`. Columns and empty states are declared once for both loading and ready layouts. For bounded results already loaded in the app, pass `pagination={{ pageSize: 8 }}` to page the rows after local search; the footer counts only the supplied rows. `limit` remains a separate, mutually exclusive display cap. Large catalogs need query-backed pagination with a stable sort and total count. - -Bound `VisualizationWidget` and `TableWidget` calls require `evidence` from `context.evidence(...)`. A bound `MetricWidget` gets evidence from its metric definition. Evidence must name at least one glossary entry or query. Static widgets may omit it. - -`MetricWidget` and `ComparisonVisual` both accept the same `metric` and `reading`. The comparison is enabled by the displayed result's range. The definition supplies formatting and evidence; a reading cannot override those or provide a second value. `favorableDirection` is optional; changes are neutral until the author defines whether up or down is favorable. - -`defineDataContent` remains available for manually managed requests. Its optional `{ date: (input) => rangeRequest }` binds comparison readings. `DataSection` handles independent requests. Low-level widgets, tabs and layout components remain available for custom interfaces. - -## Execute named queries - -```ts -const queries = defineQueryNames({ activity: "feature-activity" }); -const activity = defineOperation({ - queryNames: queries, - input: calendar.parseRequest, - output: parseActivity, - checks: [checkInput], - policy: { maxQueryRows: 100, maxDurationMs: 15000, exposeSql: true }, - async run({ query }, input) { - const result = await query(queries.activity, buildActivitySql(input)); - return parseActivityRows(result); - }, -}); -``` - -`query` inherits the operation's limit and cancellation signal; `{ limit }` can lower a particular query's bound. Names are checked by TypeScript and at runtime. The server records the SQL and query ID when execution occurs, so evidence does not need a separate result field. Browser modules import operation types with `import type`; they never import server implementations. - -## Bind evidence - -```tsx -const queries = defineQueryNames({ activity: "feature-activity" }); -// In the server operation: queryNames: queries -const identifiers = defineDataIdentifiers({ - tables: { events: { catalog: "product_analytics", schema: "analytics", name: "events" } }, - columns: { identity: { table: "events", name: "identity_uuid" } }, -}); -const { DataIdentifier } = identifiers; -const context = createDataContext(queries)({ - identifiers: identifiers.definitions, - description: <>Explore activity in ., - glossary: { - identities: { - term: "Tracked identities", - definition: <>Distinct values., - queryNames: [queries.activity], - }, - }, -}); -const evidence = context.evidence({ - id: "identities", - glossaryIds: ["identities"], - queryNames: [queries.activity], -}); -const actions = context.metric({ - id: "actions", - glossaryId: "identities", - label: "Tracked identities", - format: { kind: "count" }, -}); -const step = context.storyStep({ - id: "activity", - headline: "What people do", - visual: , - queryNames: [queries.activity], -}); -``` - -Import `defineQueryNames` from `/contract` and the context/identifier factories from `/ui`. Use the same registry in `defineOperation({ queryNames: queries, ... })`. Unknown glossary/query references fail type checks and registry validation; the server also validates returned query names. Physical source identity stays in the identifier registry for future linking. - -## API migration - -- Views that previously inferred their date variable now declare `date: { variable: "period", input: (input) => input }`, or supply `describeInput` for a non-date view. -- Numeric metrics use `value={count} format={{ kind: "count" }}`. Custom formatted JSX or strings use `content={...}` instead of `value`. -- Supply `empty` to secondary `DataSection` requests or pass a bound `useView` result. A primary `DataApp` accepts it either from `useView` or as an explicit prop. -- For alternate views of the same bound result, pass `views={[{ id, label, render }]}` and `viewLabel` to `VisualizationWidget`. Its required `isEmpty` and `empty` apply to the whole result; the widget owns selection. Use `WidgetViewTabs` directly only when views have independent empty states. -- Variable URL keys cannot use `view`, `about`, `tab`, `present`, or `step`. diff --git a/data-app/runtime/bun.lock b/data-app/runtime/bun.lock deleted file mode 100644 index 43f40e85..00000000 --- a/data-app/runtime/bun.lock +++ /dev/null @@ -1,237 +0,0 @@ -{ - "lockfileVersion": 2, - "configVersion": 1, - "workspaces": { - "": { - "name": "@altertable/data-app-runtime", - "dependencies": { - "@floating-ui/react": "0.27.20", - "@internationalized/date": "3.12.4", - "@tanstack/react-query": "5.104.0", - "fuzzysort": "4.0.2", - "lucide-react": "1.48.0", - "react-aria-components": "1.21.1", - }, - "devDependencies": { - "@types/bun": "1.4.2", - "@types/react": "19.3.0", - "@types/react-dom": "19.3.0", - "oxfmt": "0.70.0", - "oxlint": "1.85.0", - "oxlint-tsgolint": "7.0.2003", - "react": "19.3.0", - "react-dom": "19.3.0", - "typescript": "7.0.2", - }, - "peerDependencies": { - "react": ">=19", - "react-dom": ">=19", - }, - }, - }, - "packages": { - "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], - - "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], - - "@floating-ui/react": ["@floating-ui/react@0.27.20", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.9", "@floating-ui/utils": "^0.2.12", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw=="], - - "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="], - - "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], - - "@internationalized/date": ["@internationalized/date@3.12.4", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ=="], - - "@internationalized/number": ["@internationalized/number@3.6.8", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-8UmMFia46DUt+k97zKd9fKWXcWHR+k8ae3eYzILETuT2KbIvLyOfac7zesw+sJdRAAZ7Q9pM1Mk22aXp2LD0Ig=="], - - "@internationalized/string": ["@internationalized/string@3.2.10", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA=="], - - "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.70.0", "", { "os": "android", "cpu": "arm" }, "sha512-Xd7YO4/T2axEj6FTLcj4Why3mTBqFMg+x24xtorT4Lb2+1g82090GH0a/4U1m0pABGYiix2bq1pqkYrmV3f0Sw=="], - - "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.70.0", "", { "os": "android", "cpu": "arm64" }, "sha512-x9rlMYyKXdgKdYyUJzGsK1ZV8P4di/J32ipzcS6Jet6p9r9UAh28neXIMtdlSaJJycdi61Z4YkcLKLpk8ueFjg=="], - - "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.70.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IUTUPvrBVYy7POh4stXzRdz4IVC/1QSaviCWoyenSlOhGu0X9j5K07vCTM9biLjAA2Zs31l0Rj5vvRpj9n95wA=="], - - "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.70.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-vw745q870oTd6J517O24asoX4/E+eK0nxYIFoedSLqgJ+nI5En7+ZS82iZSHZ69zevQrnOXiyHP01dA+t8xD8w=="], - - "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.70.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-NO14EgSM9dFkcg+MfGPxvsKqXYs9LKaxPrOKXpv1R0rLokGGFDcCq6dBMq18dE4wlpFOovX0UZY2uh1P30O7QA=="], - - "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.70.0", "", { "os": "linux", "cpu": "arm" }, "sha512-139OEhHarj9CYoJ/i9gXlPv4KLBGtLj2toseOWYFf09QwlhklaZk+wW3aOvlqoeZtuayvkoSNVWra7WJ21s3VQ=="], - - "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.70.0", "", { "os": "linux", "cpu": "arm" }, "sha512-GEh2PY3IWTE0M24eNhTduountANSbWyDmMnzFSQE/nGg/bjPugbUgiGuFu+xdqcQSd/HKSwH80/F2yVVD48yhA=="], - - "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.70.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-En5i+UJmZSPxuSf47F2Hl5YOzKB0bicQLnGQkeTCMQ35cWLtbrSwACJKfiLqRZrk05DwSnsJkhBRaM3OURtIaA=="], - - "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.70.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WWOoV5W9Im3flVwOVrWn/2DUlOF8v5vcCip+kcNuaMpulRCh6nzzt1Su2vcL2F908YJIXNV3HvegbBHuyLwKHg=="], - - "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.70.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-YUouneIqW+5n7aE8xx/zeZ6/utr/KH7oykcGoFyd8Uz8uh591T1oKlnoWA3BsRq/ZR42oY1w4MUYvS/0e/MQOA=="], - - "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.70.0", "", { "os": "linux", "cpu": "none" }, "sha512-iEnMf21S5aGVa4hViDGY8sAQ/AHyCu2JPyrQF8P06wtHhSkD1YJBeT4m/KiGewgf7+a5XCYSCRIPcRQa1xwEoQ=="], - - "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.70.0", "", { "os": "linux", "cpu": "none" }, "sha512-91Sdniaj20fQzyMeCxMDzTP4c9s4RB8dGQ308xHhDR0n6U7+1Xq7N9klE7mfXq8iV3lRmIGSXi5X23Hn/0XX/g=="], - - "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.70.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-uUV30M6E+2TKKGMaKiwfeL4RZrviHXlUxsrYJ/jFBb+1EZy+pnFT+hF73eeWdzh5NqPOAnw0iiMAIqjqiLZPFg=="], - - "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.70.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ivMcX6kNDPhqtbOaBt/ItFlLlTlXNHLgRuNmxP6Na6UuYXRT10llpJcAPbGeRgjjb3Qzv4jwPp3fB0hui50WNQ=="], - - "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.70.0", "", { "os": "linux", "cpu": "x64" }, "sha512-w+S+fERxYmlZSyZlJK/U292FjyBoH8cCEj21/tYJX6atX5kNSn+HDkhlFQKT2zcMwUW0uAtUL/bOrlwJZwqRdA=="], - - "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.70.0", "", { "os": "none", "cpu": "arm64" }, "sha512-Zlom1Xkx257R8bk4ZI4zJsrGno2opknz1+5v5baka3nn4FPyvNSdh8JUL4CdN1S1OWRMvJ9UJQ+RfIqGGCUEfA=="], - - "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.70.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-FQgPW5R17vzt7cgrJ8eG/dqX00o2xHsqFeLfw4xzA9FRHpN/DjFo9YDonvIIXGxiEuS9F/jZPnGO+KHNKuCo4Q=="], - - "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.70.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-ZfZublNhZ+XBndMiXhkiLlPE+XyGRDa0CweeTL6t1fZypfCh1LTg7e5CvnOeTBunq15MskOcRempumSPGAaCQA=="], - - "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.70.0", "", { "os": "win32", "cpu": "x64" }, "sha512-HlIZEn+WzLQL0DszNzldiRl/DPRCX5R0Vkt6qeUPR1YHwy52hZZo4x6HoTOVmKRP2wUiwPGtKsihNY/f8KRaBg=="], - - "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@7.0.2003", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TgV33rXr6ueXBwvc+0nssUkTBSXHJxv77I8p4RCjDjCnvexHtmoPiudVRDfj3S3puMDdeQdHW6jdUgUPy3nr/w=="], - - "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@7.0.2003", "", { "os": "darwin", "cpu": "x64" }, "sha512-hY3FMAjIaPDdK3FNxyRXRfHPOFeoBn6dmnLyKZgQ2IbTTD1adXhl6PfCIqHhCPvurigv7nf7mYuWZZ19MmJzmg=="], - - "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@7.0.2003", "", { "os": "linux", "cpu": "arm64" }, "sha512-eAET4JpyfBbg8SO0K74o4R55tEjVC292pIyxGOw2XGf8x/HrPNyxwQ478jMYOM54FIVOHc8sJ6VRHxluy6lucw=="], - - "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@7.0.2003", "", { "os": "linux", "cpu": "x64" }, "sha512-GXdyO/XyqDJ3s/llR/oOktLsYNjZtWSQBy0JMc+/0gsNPvTcseKPhn9c6KcFyWmnr6H4vljYALbujonqSzzEGw=="], - - "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@7.0.2003", "", { "os": "win32", "cpu": "arm64" }, "sha512-TWauXnPfet0VgpmrstoAK58eJ4gbuwPUuUTQmYQAzE/RG1CpnxXtrKUJULf6lyerPE4KN3gM+DflIA1hOH+38Q=="], - - "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@7.0.2003", "", { "os": "win32", "cpu": "x64" }, "sha512-DoRmfe7j8VqNlukp8liRVW45GQDhzRccNenjD/pdzelgtffW47pCMd1xbJLkPaPbKnTwID3onn3VZlL7JbebEA=="], - - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.85.0", "", { "os": "android", "cpu": "arm" }, "sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg=="], - - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.85.0", "", { "os": "android", "cpu": "arm64" }, "sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA=="], - - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.85.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA=="], - - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.85.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw=="], - - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.85.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg=="], - - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.85.0", "", { "os": "linux", "cpu": "arm" }, "sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw=="], - - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.85.0", "", { "os": "linux", "cpu": "arm" }, "sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g=="], - - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.85.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg=="], - - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.85.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw=="], - - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.85.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw=="], - - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.85.0", "", { "os": "linux", "cpu": "none" }, "sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg=="], - - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.85.0", "", { "os": "linux", "cpu": "none" }, "sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA=="], - - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.85.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ=="], - - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.85.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg=="], - - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.85.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA=="], - - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.85.0", "", { "os": "none", "cpu": "arm64" }, "sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA=="], - - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.85.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA=="], - - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.85.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A=="], - - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.85.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA=="], - - "@react-types/shared": ["@react-types/shared@3.36.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-AzsuD9OfxTOZMMvTRhlN3oHBwOmFN7tDh27LzqmHt4+uOgPhJT7ZM7/kVs/8/o0WxayMUIk3hBmCFRHv1FUoag=="], - - "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], - - "@tanstack/query-core": ["@tanstack/query-core@5.104.0", "", {}, "sha512-JrC2r/JQlXt7khBSdUpsgxNvybzOg+aITa+ARRMlP2AFo93Y8vIqz077rp+e61mJetSZ0T6AJbwW1JHer1vrPQ=="], - - "@tanstack/react-query": ["@tanstack/react-query@5.104.0", "", { "dependencies": { "@tanstack/query-core": "5.104.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-e1TZmDCQnWIfiDVryIHeA6Idj+Lfx1hORLOhXS/l5wcgvtVD4yYqbTDl378sGQKIi2cSgb0TEMC9cKK8GGoJEw=="], - - "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], - - "@types/node": ["@types/node@26.6.3", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg=="], - - "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], - - "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], - - "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], - - "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], - - "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], - - "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], - - "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], - - "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], - - "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], - - "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], - - "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], - - "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], - - "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], - - "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], - - "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], - - "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], - - "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], - - "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], - - "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], - - "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], - - "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], - - "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - - "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], - - "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], - - "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], - - "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - - "fuzzysort": ["fuzzysort@4.0.2", "", {}, "sha512-3c7yD6rK1EXIsqpcu1ZVzXJugODWNMzvUij6yTiziaAHrpNS8kzFOI9mJLE8pmG4K9JOaVgzQRgklfOEk8f4Tg=="], - - "lucide-react": ["lucide-react@1.48.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ=="], - - "oxfmt": ["oxfmt@0.70.0", "", { "dependencies": { "tinypool": "2.1.2" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.70.0", "@oxfmt/binding-android-arm64": "0.70.0", "@oxfmt/binding-darwin-arm64": "0.70.0", "@oxfmt/binding-darwin-x64": "0.70.0", "@oxfmt/binding-freebsd-x64": "0.70.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.70.0", "@oxfmt/binding-linux-arm-musleabihf": "0.70.0", "@oxfmt/binding-linux-arm64-gnu": "0.70.0", "@oxfmt/binding-linux-arm64-musl": "0.70.0", "@oxfmt/binding-linux-ppc64-gnu": "0.70.0", "@oxfmt/binding-linux-riscv64-gnu": "0.70.0", "@oxfmt/binding-linux-riscv64-musl": "0.70.0", "@oxfmt/binding-linux-s390x-gnu": "0.70.0", "@oxfmt/binding-linux-x64-gnu": "0.70.0", "@oxfmt/binding-linux-x64-musl": "0.70.0", "@oxfmt/binding-openharmony-arm64": "0.70.0", "@oxfmt/binding-win32-arm64-msvc": "0.70.0", "@oxfmt/binding-win32-ia32-msvc": "0.70.0", "@oxfmt/binding-win32-x64-msvc": "0.70.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-IsHxZ4y0wQLLMhnrJblBJgZsLDzfULrJnAw5j/QqsTlMa/m3AqsbToi+W71uhBGaqlqq/PbbjvHc09TJwdv3Tw=="], - - "oxlint": ["oxlint@1.85.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.85.0", "@oxlint/binding-android-arm64": "1.85.0", "@oxlint/binding-darwin-arm64": "1.85.0", "@oxlint/binding-darwin-x64": "1.85.0", "@oxlint/binding-freebsd-x64": "1.85.0", "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", "@oxlint/binding-linux-arm-musleabihf": "1.85.0", "@oxlint/binding-linux-arm64-gnu": "1.85.0", "@oxlint/binding-linux-arm64-musl": "1.85.0", "@oxlint/binding-linux-ppc64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-musl": "1.85.0", "@oxlint/binding-linux-s390x-gnu": "1.85.0", "@oxlint/binding-linux-x64-gnu": "1.85.0", "@oxlint/binding-linux-x64-musl": "1.85.0", "@oxlint/binding-openharmony-arm64": "1.85.0", "@oxlint/binding-win32-arm64-msvc": "1.85.0", "@oxlint/binding-win32-ia32-msvc": "1.85.0", "@oxlint/binding-win32-x64-msvc": "1.85.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg=="], - - "oxlint-tsgolint": ["oxlint-tsgolint@7.0.2003", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "7.0.2003", "@oxlint-tsgolint/darwin-x64": "7.0.2003", "@oxlint-tsgolint/linux-arm64": "7.0.2003", "@oxlint-tsgolint/linux-x64": "7.0.2003", "@oxlint-tsgolint/win32-arm64": "7.0.2003", "@oxlint-tsgolint/win32-x64": "7.0.2003" }, "bin": { "tsgolint": "./bin/tsgolint.js" } }, "sha512-VnK4zlqgmgq/7ZcjzCk/WpN8kKFsYGcB85Io9qT3wL4K8Un3RKmJkp793crNETieMusPMKOA4a+Mw2wbteI6TQ=="], - - "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], - - "react-aria": ["react-aria@3.52.1", "", { "dependencies": { "@internationalized/date": "^3.12.4", "@internationalized/number": "^3.6.8", "@internationalized/string": "^3.2.10", "@react-types/shared": "^3.36.1", "@swc/helpers": "^0.5.0", "aria-hidden": "^1.2.3", "clsx": "^2.0.0", "react-stately": "3.50.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-fdZZruC9/x/joCg0mhKGs5aHpwrXLCSZ4GOJmhhYyiE0ffyEsk9MLFt9LCOCF9tn8ErTD+UDQP4oDUSlZkmpCg=="], - - "react-aria-components": ["react-aria-components@1.21.1", "", { "dependencies": { "@internationalized/date": "^3.12.4", "@internationalized/string": "^3.2.10", "@react-types/shared": "^3.36.1", "@swc/helpers": "^0.5.0", "client-only": "^0.0.1", "react-aria": "3.52.1", "react-stately": "3.50.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-J88WflYY0z+EhLX0ce+GjFlQ3ag3ubIgfUTjpKSrBQBu92Xtl4Ub9o118HItUddtbk1Ido0jzyPy94/klZy1hg=="], - - "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], - - "react-stately": ["react-stately@3.50.0", "", { "dependencies": { "@internationalized/date": "^3.12.4", "@internationalized/number": "^3.6.8", "@internationalized/string": "^3.2.10", "@react-types/shared": "^3.36.1", "@swc/helpers": "^0.5.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-TnckvpDQGU0672wEaLZqdzvhuSeXWjgW6vijMWxiKOxc8CosQUfPGISdcZyfHqjX3XMjEtRxj4w9HumvX4h4mw=="], - - "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], - - "tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="], - - "tinypool": ["tinypool@2.1.2", "", {}, "sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww=="], - - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], - - "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], - - "use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="], - } -} diff --git a/data-app/runtime/package.json b/data-app/runtime/package.json deleted file mode 100644 index fdab5c7d..00000000 --- a/data-app/runtime/package.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "@altertable/data-app", - "version": "0.59.1", - "private": true, - "files": [ - "src", - "package.json", - "README.md" - ], - "type": "module", - "exports": { - "./contract": "./src/core/contract.ts", - "./config": "./src/core/config.ts", - "./format": "./src/core/format.ts", - "./appearance": "./src/core/appearance.ts", - "./client": "./src/client/index.ts", - "./server": "./src/server/index.ts", - "./react": "./src/react/index.ts" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "lint": "oxlint --type-aware src tests", - "format": "oxfmt src tests", - "format:check": "oxfmt --check src tests", - "test": "bun test tests" - }, - "dependencies": { - "@floating-ui/react": "0.27.20", - "@internationalized/date": "3.12.4", - "@tanstack/react-query": "5.104.0", - "fuzzysort": "4.0.2", - "lucide-react": "1.48.0", - "react-aria-components": "1.21.1" - }, - "devDependencies": { - "@types/bun": "1.4.2", - "@types/react": "19.3.0", - "@types/react-dom": "19.3.0", - "oxfmt": "0.70.0", - "oxlint": "1.85.0", - "oxlint-tsgolint": "7.0.2003", - "react": "19.3.0", - "react-dom": "19.3.0", - "typescript": "7.0.2" - }, - "peerDependencies": { - "react": ">=19", - "react-dom": ">=19" - } -} diff --git a/data-app/runtime/src/assets.d.ts b/data-app/runtime/src/assets.d.ts deleted file mode 100644 index cbe652db..00000000 --- a/data-app/runtime/src/assets.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module "*.css"; diff --git a/data-app/runtime/src/client/index.ts b/data-app/runtime/src/client/index.ts deleted file mode 100644 index 5dd78dcd..00000000 --- a/data-app/runtime/src/client/index.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { DataOperations, DisclosedQuery } from "../core/contract.ts"; - -export type InputOf = T extends { input: (value: unknown) => infer Input } ? Input : never; -export type OutputOf = T extends { output: (value: unknown) => infer Output } ? Output : never; - -/** - * Parsed operation data and query evidence. `queries` is present only when SQL disclosure is - * allowed. - */ -export type DataResponse = { - data: Output; - /** The exact browser input that produced this response. Never infer it from current controls. */ - input: Input; - requestId: string; - queriedAt: string; - queryIds: string[]; - queries?: DisclosedQuery[]; -}; - -export class DataAppError extends Error { - constructor( - message: string, - public readonly code: string, - public readonly requestId?: string, - ) { - super(message); - this.name = "DataAppError"; - } -} - -export type DataClient = { - query( - name: Name, - input: InputOf, - options?: { signal?: AbortSignal }, - ): Promise, InputOf>>; -}; - -/** Browser client for named operations; it sends inputs, never SQL or lakehouse credentials. */ -export function createDataClient( - options: { - endpoint?: string; - fetch?: typeof fetch; - } = {}, -): DataClient { - const endpoint = (options.endpoint ?? "/api/data").replace(/\/$/, ""); - const request = options.fetch ?? globalThis.fetch; - return { - async query(name, input, { signal } = {}) { - const response = await request(`${endpoint}/${encodeURIComponent(name)}`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(input), - signal, - }); - let body: Omit>, "input"> & { - error?: { code: string; message: string; requestId?: string }; - }; - try { - const parsed: unknown = await response.json(); - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) - throw new Error("Invalid response envelope."); - body = parsed as typeof body; - } catch (error) { - if (signal?.aborted) throw error; - throw new DataAppError( - response.ok ? "The data response was invalid." : "Could not load data.", - response.ok ? "invalid_response" : "request_failed", - ); - } - if (!response.ok) - throw new DataAppError( - body.error?.message ?? "Could not load data.", - body.error?.code ?? "request_failed", - body.error?.requestId, - ); - return { ...body, input }; - }, - }; -} diff --git a/data-app/runtime/src/core/appearance.ts b/data-app/runtime/src/core/appearance.ts deleted file mode 100644 index 54a46d63..00000000 --- a/data-app/runtime/src/core/appearance.ts +++ /dev/null @@ -1,267 +0,0 @@ -export type AppearanceSettings = { - mode: "light" | "dark" | "system"; - baseColor: "neutral" | "slate" | "warm"; - accentColor: string; - darkAccentColor?: string; - chartColors: string[]; - density: "compact" | "comfortable" | "spacious"; - cornerRadius: "none" | "small" | "medium" | "large"; - elevation: "flat" | "subtle" | "raised"; - typography: { body: string; heading: string }; -}; - -export type ThemeMode = AppearanceSettings["mode"]; - -export type ThemeController = { - getMode: () => ThemeMode; - setMode: (mode: ThemeMode) => void; - subscribe: (listener: () => void) => () => void; -}; - -const defaults: AppearanceSettings = { - mode: "light", - baseColor: "neutral", - accentColor: "#405d47", - darkAccentColor: "#a6c4ad", - chartColors: ["#285fc0", "#a95319", "#147862", "#7243aa", "#aa3958", "#475569"], - density: "comfortable", - cornerRadius: "medium", - elevation: "subtle", - typography: { body: "system", heading: "system" }, -}; - -function record(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function oneOf(value: unknown, options: readonly T[]): value is T { - return typeof value === "string" && options.includes(value as T); -} - -function color(value: unknown): value is string { - return typeof value === "string" && /^#[0-9a-fA-F]{6}$/.test(value); -} - -function font(value: unknown): value is string { - return ( - typeof value === "string" && value.length > 0 && value.length <= 120 && !/[;{}()]/.test(value) - ); -} - -export function parseAppearance(value: unknown): AppearanceSettings { - if (value === undefined) - return { - ...defaults, - chartColors: [...defaults.chartColors], - typography: { ...defaults.typography }, - }; - if ( - !record(value) || - Object.keys(value).some( - (key) => - ![ - "mode", - "baseColor", - "accentColor", - "darkAccentColor", - "chartColors", - "density", - "cornerRadius", - "elevation", - "typography", - ].includes(key), - ) - ) { - throw new Error("Invalid appearance settings."); - } - const typography = value.typography; - if ( - (value.mode !== undefined && !oneOf(value.mode, ["light", "dark", "system"])) || - (value.baseColor !== undefined && !oneOf(value.baseColor, ["neutral", "slate", "warm"])) || - (value.accentColor !== undefined && !color(value.accentColor)) || - (value.darkAccentColor !== undefined && !color(value.darkAccentColor)) || - (value.chartColors !== undefined && - (!Array.isArray(value.chartColors) || - value.chartColors.length < 1 || - value.chartColors.length > 8 || - !value.chartColors.every(color))) || - (value.density !== undefined && - !oneOf(value.density, ["compact", "comfortable", "spacious"])) || - (value.cornerRadius !== undefined && - !oneOf(value.cornerRadius, ["none", "small", "medium", "large"])) || - (value.elevation !== undefined && !oneOf(value.elevation, ["flat", "subtle", "raised"])) || - (typography !== undefined && - (!record(typography) || - Object.keys(typography).some((key) => key !== "body" && key !== "heading") || - (typography.body !== undefined && !font(typography.body)) || - (typography.heading !== undefined && !font(typography.heading)))) - ) { - throw new Error("Invalid appearance settings."); - } - return { - mode: (value.mode ?? defaults.mode) as AppearanceSettings["mode"], - baseColor: (value.baseColor ?? defaults.baseColor) as AppearanceSettings["baseColor"], - accentColor: (value.accentColor ?? defaults.accentColor) as string, - darkAccentColor: (value.darkAccentColor ?? - (value.accentColor === undefined ? defaults.darkAccentColor : undefined)) as - | string - | undefined, - chartColors: (value.chartColors ?? defaults.chartColors) as string[], - density: (value.density ?? defaults.density) as AppearanceSettings["density"], - cornerRadius: (value.cornerRadius ?? - defaults.cornerRadius) as AppearanceSettings["cornerRadius"], - elevation: (value.elevation ?? defaults.elevation) as AppearanceSettings["elevation"], - typography: { - body: - record(typography) && typeof typography.body === "string" - ? typography.body - : defaults.typography.body, - heading: - record(typography) && typeof typography.heading === "string" - ? typography.heading - : defaults.typography.heading, - }, - }; -} - -const palettes = { - neutral: { - light: ["#ffffff", "#ffffff", "#f6f7f7", "#202124", "#687078", "#e3e6e8"], - dark: ["#151719", "#202326", "#292d31", "#f2f3f4", "#aeb5bc", "#3b4248"], - }, - slate: { - light: ["#f8fafc", "#ffffff", "#f1f5f9", "#17212f", "#607083", "#dce3ea"], - dark: ["#111820", "#1b2530", "#263340", "#f0f4f8", "#a8b5c3", "#3a4857"], - }, - warm: { - light: ["#fbfaf8", "#ffffff", "#f5f2ed", "#292723", "#736e66", "#e8e2da"], - dark: ["#1b1916", "#25221e", "#302c27", "#f5f1eb", "#bcb3a8", "#494239"], - }, -} as const; - -const spaces = { - compact: ["4px", "8px", "12px", "18px", "24px"], - comfortable: ["5px", "10px", "16px", "24px", "32px"], - spacious: ["6px", "12px", "20px", "30px", "40px"], -} as const; - -const radii = { - none: ["0px", "0px", "0px"], - small: ["4px", "7px", "9px"], - medium: ["7px", "12px", "16px"], - large: ["10px", "18px", "24px"], -} as const; - -function fontStack(family: string): string { - return family === "system" - ? "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, ui-sans-serif, sans-serif" - : `${JSON.stringify(family)}, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, ui-sans-serif, sans-serif`; -} - -/** - * Install semantic tokens on the document root, including portaled UI. Returns a cleanup for - * system-theme listening. - */ -export function applyAppearance(value: unknown): () => void { - const settings = parseAppearance(value); - const root = document.documentElement; - const preference = window.matchMedia("(prefers-color-scheme: dark)"); - const [xs, sm, md, lg, xl] = spaces[settings.density]; - const [control, surface, overlay] = radii[settings.cornerRadius]; - const tokens: Record = { - "--at-font": fontStack(settings.typography.body), - "--at-font-heading": fontStack(settings.typography.heading), - "--at-space-xs": xs, - "--at-space-sm": sm, - "--at-space-md": md, - "--at-space-lg": lg, - "--at-space-xl": xl, - "--at-layout-gap": `clamp(${md}, 2.5vw, ${lg})`, - "--at-radius-control": control, - "--at-radius-surface": surface, - "--at-radius-overlay": overlay, - "--at-shadow-overlay": - settings.elevation === "flat" - ? "none" - : settings.elevation === "raised" - ? "0 24px 72px rgb(0 0 0 / 24%)" - : "0 18px 50px rgb(0 0 0 / 16%)", - "--at-shadow-surface": - settings.elevation === "flat" - ? "none" - : settings.elevation === "raised" - ? "0 8px 28px rgb(20 28 40 / 10%)" - : "0 3px 16px rgb(20 28 40 / 5%)", - }; - function applyColors(): void { - const dark = settings.mode === "dark" || (settings.mode === "system" && preference.matches); - const [background, surfaceColor, subtle, text, muted, border] = - palettes[settings.baseColor][dark ? "dark" : "light"]; - const accent = dark - ? (settings.darkAccentColor ?? `color-mix(in srgb, ${settings.accentColor} 50%, white)`) - : settings.accentColor; - settings.chartColors.forEach((chartColor, index) => { - tokens[`--at-chart-${index + 1}`] = dark - ? `color-mix(in srgb, ${chartColor} 60%, white)` - : chartColor; - }); - Object.assign(tokens, { - "--at-background": background, - "--at-surface": surfaceColor, - "--at-subtle": subtle, - "--at-text": text, - "--at-muted": muted, - "--at-border": border, - "--at-accent": accent, - "--at-control-focus-border": accent, - "--at-accent-hover": `color-mix(in srgb, ${accent} 80%, ${dark ? "white" : "black"})`, - "--at-accent-subtle": `color-mix(in srgb, ${accent} ${dark ? 22 : 12}%, ${surfaceColor})`, - "--at-on-accent": dark ? background : "#ffffff", - "--at-danger": dark ? "#f97066" : "#b42318", - "--at-backdrop": dark ? "rgb(0 0 0 / 55%)" : "rgb(15 23 30 / 22%)", - "--at-code-surface": dark ? "#242a31" : "#f4f6f9", - "--at-code-text": dark ? "#e5e9ef" : "#273242", - }); - root.style.colorScheme = dark ? "dark" : "light"; - for (const [name, token] of Object.entries(tokens)) root.style.setProperty(name, token); - } - applyColors(); - if (settings.mode === "system") preference.addEventListener("change", applyColors); - return () => { - preference.removeEventListener("change", applyColors); - }; -} - -/** Viewer color mode persists independently of app-authored brand tokens. */ -export function createThemeController(value: unknown): ThemeController { - const settings = parseAppearance(value); - const storageKey = "altertable.data-app.theme-mode"; - let mode = settings.mode; - try { - const saved = window.localStorage.getItem(storageKey); - if (oneOf(saved, ["light", "dark", "system"])) mode = saved; - } catch { - // Storage can be unavailable in private browsing or embedded contexts. - } - let stopAppearance = applyAppearance({ ...settings, mode }); - const listeners = new Set<() => void>(); - return { - getMode: () => mode, - setMode(nextMode) { - if (nextMode === mode) return; - stopAppearance(); - mode = nextMode; - stopAppearance = applyAppearance({ ...settings, mode }); - try { - window.localStorage.setItem(storageKey, mode); - } catch { - // The selection still applies to this page. - } - listeners.forEach((listener) => listener()); - }, - subscribe(listener) { - listeners.add(listener); - return () => listeners.delete(listener); - }, - }; -} diff --git a/data-app/runtime/src/core/config.ts b/data-app/runtime/src/core/config.ts deleted file mode 100644 index a02ef730..00000000 --- a/data-app/runtime/src/core/config.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type DataAppConfig = { - title: string; - scope: { organization: string; environment: string }; - appearance: unknown; -}; - -export function dataAppTitle(config: Pick): string { - return `${config.title} • ${config.scope.organization}/${config.scope.environment} • Altertable app`; -} diff --git a/data-app/runtime/src/core/contract.ts b/data-app/runtime/src/core/contract.ts deleted file mode 100644 index 8ab6fa99..00000000 --- a/data-app/runtime/src/core/contract.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { parseDate, today } from "@internationalized/date"; -import { formatDateRange } from "./format.ts"; - -export type QueryResult = { - columns: { name: string; type?: string }[]; - rows: unknown[][]; - queryId?: string; -}; - -export function parseEmptyInput(value: unknown): Record { - if ( - typeof value !== "object" || - value === null || - Array.isArray(value) || - Object.keys(value).length - ) { - throw new Error("This operation takes no inputs."); - } - return {}; -} - -export function parseTrue(value: unknown): true { - if (value !== true) throw new Error("Expected a successful check."); - return true; -} - -export type DateRangeInput = { start: string; end: string }; -export type DateRangeRequest = { range: DateRangeInput; comparison: DateRangeInput | null }; - -/** The immediately preceding, equally long set of calendar days. */ -export function previousDateRange({ start, end }: DateRangeInput): DateRangeInput { - const first = parseDate(start); - const last = parseDate(end); - const days = (last.toDate("UTC").getTime() - first.toDate("UTC").getTime()) / 86_400_000 + 1; - if (days < 1) throw new Error("Date range must start before it ends."); - const previousEnd = first.subtract({ days: 1 }); - return { - start: previousEnd.subtract({ days: days - 1 }).toString(), - end: previousEnd.toString(), - }; -} - -export type ReportingPeriod = - | { kind: "rolling"; amount: number; unit: "hour" | "day"; end: string } - | { kind: "calendar"; start: string; end: string; timeZone: string }; - -export type DateRangeContractOptions = { - minDate?: string; - maxDate?: string | (() => string); - maxRangeDays: number; - timeZone: string; - completeDays?: boolean; -}; - -/** One source policy for the server parser, URL variable, and displayed reporting period. */ -export function defineDateRangeContract(options: DateRangeContractOptions) { - if (!Number.isInteger(options.maxRangeDays) || options.maxRangeDays < 1) - throw new Error("Date range needs a positive maxRangeDays."); - if (options.minDate) parseDate(options.minDate); - if (typeof options.maxDate === "string") parseDate(options.maxDate); - today(options.timeZone); - function bounds() { - const latest = today(options.timeZone).subtract({ - days: options.completeDays === false ? 0 : 1, - }); - const sourceMax = typeof options.maxDate === "function" ? options.maxDate() : options.maxDate; - const maxDate = - sourceMax && parseDate(sourceMax).compare(latest) < 0 - ? parseDate(sourceMax).toString() - : latest.toString(); - return { - minDate: options.minDate, - maxDate, - maxRangeDays: options.maxRangeDays, - timeZone: options.timeZone, - }; - } - const period = ({ start, end }: DateRangeInput): ReportingPeriod => ({ - kind: "calendar", - start, - end, - timeZone: options.timeZone, - }); - const describeInput = (input: DateRangeInput) => `${formatDateRange(input)} ${options.timeZone}`; - const comparison = (input: DateRangeInput): DateRangeInput | null => { - const previous = previousDateRange(input); - return options.minDate && previous.start < options.minDate ? null : previous; - }; - const request = (value: unknown, compare = false): DateRangeRequest => { - const range = parseDateRangeInput(value, bounds()); - const previous = compare ? comparison(range) : null; - if (compare && !previous) throw new Error("Comparison is outside available source coverage."); - return { range, comparison: previous }; - }; - const parseRequest = (value: unknown): DateRangeRequest => { - if (!value || typeof value !== "object") throw new Error("Choose a date range."); - const input = value as Record; - const parsed = request(input.range, input.comparison != null); - if (input.comparison != null) { - const supplied = parseDateRangeInput(input.comparison, bounds()); - if (supplied.start !== parsed.comparison?.start || supplied.end !== parsed.comparison?.end) - throw new Error("Comparison must be the preceding equal-length range."); - } - return parsed; - }; - return { - request, - parseRequest, - bounds, - parse: (value: unknown) => parseDateRangeInput(value, bounds()), - period, - comparison, - describeInput, - view: { period, describeInput }, - }; -} - -export type DateRangeContract = ReturnType; - -/** Validate calendar dates and a bounded inclusive range on the server. */ -export function parseDateRangeInput( - value: unknown, - { minDate, maxDate, maxRangeDays }: { minDate?: string; maxDate?: string; maxRangeDays: number }, -): DateRangeInput { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("Choose a date range."); - } - const { start, end } = value as Record; - function day(input: unknown): number { - if (typeof input !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(input)) { - throw new Error("Dates must be YYYY-MM-DD."); - } - const timestamp = Date.parse(`${input}T00:00:00Z`); - if (!Number.isFinite(timestamp) || new Date(timestamp).toISOString().slice(0, 10) !== input) { - throw new Error("Choose valid calendar dates."); - } - return timestamp; - } - const first = day(start); - const last = day(end); - if (!Number.isInteger(maxRangeDays) || maxRangeDays < 1) { - throw new Error("Maximum range days must be positive."); - } - if ( - first > last || - (last - first) / 86_400_000 >= maxRangeDays || - (minDate !== undefined && first < day(minDate)) || - (maxDate !== undefined && last > day(maxDate)) - ) { - throw new Error(`Choose up to ${maxRangeDays} days within the available range.`); - } - return { start: start as string, end: end as string }; -} - -/** Accepts numeric strings from DuckDB; rejects negative, fractional, and unsafe integers. */ -export function parseCount(value: unknown): number { - const number = typeof value === "number" || typeof value === "string" ? Number(value) : NaN; - if (!Number.isSafeInteger(number) || number < 0 || (typeof value === "string" && !value.trim())) { - throw new Error("Invalid count in query result."); - } - return number; -} - -export function parseLabel(value: unknown, maxLength = 100): string { - if (typeof value !== "string" || !value || value.length > maxLength) { - throw new Error("Invalid label in query result."); - } - return value; -} - -/** Rejects missing or duplicate columns and rows whose width differs from the column list. */ -export function rowsAsRecords( - result: QueryResult, - requiredColumns: readonly string[], -): Record[] { - const names = result.columns.map((column) => column.name); - if ( - new Set(names).size !== names.length || - requiredColumns.some((name) => !names.includes(name)) - ) { - throw new Error("Query result columns do not match the expected shape."); - } - return result.rows.map((row) => { - if (row.length !== names.length) throw new Error("Query result row has the wrong width."); - return Object.fromEntries(names.map((name, index) => [name, row[index]])); - }); -} - -/** One named statement an operation ran. Returned only when SQL disclosure is allowed. */ -export type DisclosedQuery = { name: string; statement: string; queryId?: string }; - -export function defineQueryNames>(names: Names): Names { - const values = Object.values(names); - if (values.some((name) => !name.trim()) || new Set(values).size !== values.length) - throw new Error("Query names must be nonempty and unique."); - return names; -} - -export class DataSourceError extends Error { - queryName?: string; - - constructor( - public readonly reason: - | "unauthorized" - | "forbidden" - | "rate_limited" - | "query_rejected" - | "unavailable", - public readonly status?: number, - ) { - super(`Data source ${reason}${status ? ` (${status})` : ""}.`); - this.name = "DataSourceError"; - } -} - -/** Server-only query interface supplied by a local or hosted adapter. */ -export type Lakehouse = { - queryAll( - statement: string, - options: { limit: number; signal: AbortSignal; name?: string }, - ): Promise; -}; - -export type OperationContext = { lakehouse: Lakehouse; signal: AbortSignal }; - -/** - * Both parsers run on the server before results cross the JSON boundary. Schema libraries can be - * used inside either parser. - */ -export type DataOperation = { - input: (value: unknown) => Input; - output: (value: unknown) => Output; - run: (context: OperationContext, input: Input) => Promise; - checks: readonly Input[]; - queryNames?: Readonly>; - policy: { - maxQueryRows: number; - maxDurationMs: number; - maxResponseBytes?: number; - exposeSql?: boolean; - }; -}; - -export type OperationQuery>> = ( - name: Names[keyof Names], - statement: string, - options?: { limit?: number }, -) => Promise; - -export function defineOperation< - Input, - Output, - const Names extends Readonly> = Record, ->( - operation: Omit, "run" | "queryNames"> & { - queryNames?: Names; - run: ( - context: OperationContext & { query: OperationQuery> }, - input: Input, - ) => Promise; - }, -): DataOperation & { queryNames?: Names } { - if ( - !Array.isArray(operation.checks) || - !operation.checks.length || - !Number.isInteger(operation.policy.maxQueryRows) || - operation.policy.maxQueryRows < 1 || - !Number.isInteger(operation.policy.maxDurationMs) || - operation.policy.maxDurationMs < 1 || - (operation.policy.maxResponseBytes !== undefined && - (!Number.isInteger(operation.policy.maxResponseBytes) || - operation.policy.maxResponseBytes < 1)) - ) { - throw new Error("Each data operation needs check inputs and positive row and duration limits."); - } - if (operation.queryNames) defineQueryNames(operation.queryNames); - for (const input of operation.checks) operation.input(input); - return { - ...operation, - run(context, input) { - const query: OperationQuery = (name, statement, options) => { - if (!operation.queryNames || !Object.values(operation.queryNames).includes(name)) - throw new Error(`Unknown query name: ${name}.`); - return context.lakehouse.queryAll(statement, { - name, - limit: options?.limit ?? operation.policy.maxQueryRows, - signal: context.signal, - }); - }; - return operation.run({ ...context, query }, input); - }, - }; -} - -export const connectionQueryNames = defineQueryNames({ connection: "connection-check" }); - -/** Success requires a bounded SQL query; it does not establish access to a particular dataset. */ -export function connectionCheck(): DataOperation, true> { - return defineOperation({ - input: parseEmptyInput, - output: parseTrue, - checks: [{}], - queryNames: connectionQueryNames, - policy: { maxQueryRows: 1, maxDurationMs: 15_000, exposeSql: true }, - async run({ query }): Promise { - await query(connectionQueryNames.connection, "SELECT 1 AS connection_check"); - return true; - }, - }); -} - -export type DataOperations = Record< - string, - { - input: (value: unknown) => unknown; - output: (value: unknown) => unknown; - run: (context: OperationContext, input: never) => Promise; - checks: readonly unknown[]; - queryNames?: Readonly>; - policy: DataOperation["policy"]; - } ->; diff --git a/data-app/runtime/src/core/data-view.ts b/data-app/runtime/src/core/data-view.ts deleted file mode 100644 index 293d12fe..00000000 --- a/data-app/runtime/src/core/data-view.ts +++ /dev/null @@ -1,65 +0,0 @@ -export type DataView = - | { kind: "loading" } - | { kind: "empty"; input: Input } - | { kind: "error"; error: Error } - | { kind: "ready"; data: T; input: Input } - | { kind: "updating"; data: T; requestedInput: Input; displayedInput: Input; message: string } - | { - kind: "stale-error"; - data: T; - requestedInput: Input; - displayedInput: Input; - error: Error; - message: string; - }; - -export type DataSnapshot = { data: Data; input: Input }; - -/** Keep the requested input separate from the input that produced visible data. */ -export function resolveDataView({ - requestedInput, - current, - previous, - pending, - error, - sameInput, - describe, - isEmpty, -}: { - requestedInput: Input; - current?: DataSnapshot; - previous?: DataSnapshot; - pending: boolean; - error?: Error; - sameInput: (a: Input, b: Input) => boolean; - describe: (input: Input) => string; - isEmpty: (data: Data) => boolean; -}): DataView { - const shown = current ?? previous; - if (!shown) return error ? { kind: "error", error } : { kind: "loading" }; - const currentInput = sameInput(shown.input, requestedInput); - if (error) - return { - kind: "stale-error", - data: shown.data, - displayedInput: shown.input, - requestedInput, - error, - message: currentInput - ? `Couldn’t refresh. Showing the last result for ${describe(shown.input)}.` - : `Couldn’t refresh. Showing ${describe(shown.input)} while ${describe(requestedInput)} is unavailable.`, - }; - if (pending || !currentInput) - return { - kind: "updating", - data: shown.data, - displayedInput: shown.input, - requestedInput, - message: currentInput - ? `Updating ${describe(requestedInput)}…` - : `Showing ${describe(shown.input)} while loading ${describe(requestedInput)}…`, - }; - return isEmpty(shown.data) - ? { kind: "empty", input: shown.input } - : { kind: "ready", data: shown.data, input: shown.input }; -} diff --git a/data-app/runtime/src/core/date-range.ts b/data-app/runtime/src/core/date-range.ts deleted file mode 100644 index c9a214b5..00000000 --- a/data-app/runtime/src/core/date-range.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { parseDate, today, type CalendarDate } from "@internationalized/date"; - -/** ISO calendar dates; the app operation defines timezone and inclusive bounds. */ -export type DateRange = { start: string; end: string }; - -export type DatePresetId = - | "day" - | "last-3" - | "last-7" - | "last-14" - | "last-30" - | "last-90" - | "this-week" - | "previous-week" - | "this-month" - | "previous-month"; -export type DatePreset = { id: DatePresetId; label: string; range: DateRange }; - -export function inclusiveDays(range: DateRange): number { - return ( - (parseDate(range.end).toDate("UTC").getTime() - - parseDate(range.start).toDate("UTC").getTime()) / - 86_400_000 + - 1 - ); -} - -function monday(date: CalendarDate): CalendarDate { - return date.subtract({ days: (date.toDate("UTC").getUTCDay() + 6) % 7 }); -} - -export function withinBounds( - range: DateRange, - minDate?: string, - maxDate?: string, - maxRangeDays?: number, -): boolean { - try { - const start = parseDate(range.start); - const end = parseDate(range.end); - const days = inclusiveDays(range); - return ( - days >= 1 && - (!maxRangeDays || days <= maxRangeDays) && - (!minDate || start.compare(parseDate(minDate)) >= 0) && - (!maxDate || end.compare(parseDate(maxDate)) <= 0) - ); - } catch { - return false; - } -} - -/** Only offer ranges the app says it can query. Dates and range lengths are inclusive. */ -export function availableDatePresets({ - minDate, - maxDate, - maxRangeDays, - timeZone = "UTC", -}: { - minDate?: string; - maxDate?: string; - maxRangeDays?: number; - timeZone?: string; -}): DatePreset[] { - const currentDay = today(timeZone); - const latest = - maxDate && parseDate(maxDate).compare(currentDay) < 0 ? parseDate(maxDate) : currentDay; - const earliest = minDate ? parseDate(minDate) : null; - const completeDays = latest.compare(currentDay.subtract({ days: 1 })) === 0; - const historical = latest.compare(currentDay.subtract({ days: 1 })) < 0; - const options: DatePreset[] = []; - const seen = new Set(); - function add(id: DatePresetId, label: string, start: CalendarDate, end = latest) { - const days = (end.toDate("UTC").getTime() - start.toDate("UTC").getTime()) / 86_400_000 + 1; - const range = { start: start.toString(), end: end.toString() }; - const key = `${range.start}/${range.end}`; - if ( - days < 1 || - (maxRangeDays && days > maxRangeDays) || - (earliest && start.compare(earliest) < 0) || - end.compare(latest) > 0 || - seen.has(key) - ) - return; - seen.add(key); - options.push({ id, label, range }); - } - add("day", historical ? "Latest available day" : completeDays ? "Yesterday" : "Today", latest); - for (const days of [3, 7, 14, 30, 90]) { - add( - `last-${days}` as DatePresetId, - historical - ? `Latest ${days} available days` - : completeDays - ? `Last ${days} complete days` - : `Last ${days} days`, - latest.subtract({ days: days - 1 }), - ); - } - const weekStart = monday(currentDay); - if (latest.compare(weekStart) >= 0) - add("this-week", completeDays ? "This week through yesterday" : "This week", weekStart); - add( - "previous-week", - "Previous week", - weekStart.subtract({ days: 7 }), - weekStart.subtract({ days: 1 }), - ); - const monthStart = currentDay.set({ day: 1 }); - if (latest.compare(monthStart) >= 0) - add("this-month", completeDays ? "This month through yesterday" : "This month", monthStart); - const previousMonthEnd = monthStart.subtract({ days: 1 }); - add("previous-month", "Previous month", previousMonthEnd.set({ day: 1 }), previousMonthEnd); - return options; -} diff --git a/data-app/runtime/src/core/format.ts b/data-app/runtime/src/core/format.ts deleted file mode 100644 index 8196b1dd..00000000 --- a/data-app/runtime/src/core/format.ts +++ /dev/null @@ -1,89 +0,0 @@ -type CommonOptions = { locale?: string; missing?: string }; - -/** Compact, unambiguous calendar dates for labels and request status. */ -export function formatDateRange({ start, end }: { start: string; end: string }): string { - const first = new Date(`${start}T00:00:00Z`); - const last = new Date(`${end}T00:00:00Z`); - const format = (date: Date, options: Intl.DateTimeFormatOptions) => - new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(date); - const full = (date: Date) => format(date, { month: "short", day: "numeric", year: "numeric" }); - if (start === end) return full(first); - if (first.getUTCFullYear() !== last.getUTCFullYear()) return `${full(first)}–${full(last)}`; - const firstMonth = format(first, { month: "short" }); - const lastMonth = format(last, { month: "short" }); - const firstDay = first.getUTCDate(); - const lastDay = last.getUTCDate(); - return firstMonth === lastMonth - ? `${firstMonth} ${firstDay}–${lastDay}, ${last.getUTCFullYear()}` - : `${firstMonth} ${firstDay}–${lastMonth} ${lastDay}, ${last.getUTCFullYear()}`; -} - -export type MetricFormat = - | { kind: "count"; compact?: boolean; locale?: string } - | { kind: "ratio"; maximumFractionDigits?: number; locale?: string } - | { kind: "currency"; currency: string; locale?: string }; - -export function formatMetric(value: number, format: MetricFormat): string { - if (format.kind === "count") return formatCount(value, format); - if (format.kind === "ratio") return formatPercent(value, format); - return formatNumber(value, { - style: "currency", - currency: format.currency, - locale: format.locale, - }); -} - -/** Missing and non-finite values use the `missing` label; negative zero renders as zero. */ -export function formatNumber( - value: number | null | undefined, - options: CommonOptions & Intl.NumberFormatOptions = {}, -): string { - const { locale = "en-US", missing = "—", ...numberOptions } = options; - return value === null || value === undefined || !Number.isFinite(value) - ? missing - : new Intl.NumberFormat(locale, numberOptions).format(Object.is(value, -0) ? 0 : value); -} - -/** Negative and fractional counts render as missing. Compact notation is opt-in. */ -export function formatCount( - value: number | null | undefined, - options: CommonOptions & { compact?: boolean } = {}, -): string { - const { compact = false, ...common } = options; - if (value !== null && value !== undefined && (!Number.isInteger(value) || value < 0)) { - return common.missing ?? "—"; - } - return formatNumber(value, { - ...common, - notation: compact ? "compact" : "standard", - maximumFractionDigits: compact ? 1 : 0, - }); -} - -const pluralRules = new Intl.PluralRules(); - -export function pluralize(count: number, singular: string, plural = `${singular}s`): string { - return pluralRules.select(count) === "one" ? singular : plural; -} - -/** Accepts a ratio (0.116), not a percentage (11.6). */ -export function formatPercent( - ratio: number | null | undefined, - options: CommonOptions & { maximumFractionDigits?: number } = {}, -): string { - if (ratio === null || ratio === undefined || !Number.isFinite(ratio)) { - return options.missing ?? "—"; - } - const digits = options.maximumFractionDigits ?? (Math.abs(ratio) < 0.01 ? 2 : 1); - const percentOptions = { - locale: options.locale, - style: "percent" as const, - maximumFractionDigits: digits, - }; - const smallestVisibleRatio = 10 ** -digits / 100; - if (ratio !== 0 && Math.round(Math.abs(ratio) / smallestVisibleRatio) === 0) { - const boundary = formatNumber(smallestVisibleRatio, percentOptions); - return ratio > 0 ? `<${boundary}` : `>−${boundary}`; - } - return formatNumber(ratio, percentOptions); -} diff --git a/data-app/runtime/src/core/reading.ts b/data-app/runtime/src/core/reading.ts deleted file mode 100644 index 6f4426e7..00000000 --- a/data-app/runtime/src/core/reading.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { DateRangeRequest } from "./contract.ts"; - -export type DataReading = - | { loading: true; value?: never } - | { loading: false; value: Value }; - -export type MetricValues = { current: number; previous?: number | null }; -export type MetricReading = DataReading; diff --git a/data-app/runtime/src/core/variables.ts b/data-app/runtime/src/core/variables.ts deleted file mode 100644 index 109cbff2..00000000 --- a/data-app/runtime/src/core/variables.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { availableDatePresets, type DatePresetId, type DateRange } from "./date-range.ts"; -import type { DateRangeContract, DateRangeRequest } from "./contract.ts"; - -export type HistoryMode = "push" | "replace"; - -/** An app-owned value with one URL representation. Controls never parse or write routes. */ -export type AppVariable = { - kind: Value extends string ? "text" | "select" : "dateRange"; - label?: string; - options?: readonly { id: string; label: string }[]; - urlKeys: readonly string[]; - defaultValue: Value; - history: HistoryMode; - read: (params: URLSearchParams) => Value; - write: (value: Value) => Record; - valid: (value: Value) => boolean; - same: (left: Value, right: Value) => boolean; -}; - -export type VariableCollection = Record | DateRangeVariable>; -export type AppVariableValues = { - [Key in keyof Variables]: Variables[Key] extends AppVariable ? Value : never; -}; - -/** Name variables once in the app. URL keys must be unique across its controls. */ -export function defineAppVariables( - variables: Variables, -): Variables { - const owners = new Map(); - for (const [name, variable] of Object.entries(variables)) { - for (const key of variable.urlKeys) { - if (["view", "about", "tab", "present", "step"].includes(key)) - throw new Error(`Variable ${name} uses reserved URL key: ${key}.`); - if (!key || owners.has(key)) - throw new Error(`Variable ${name} has an empty or duplicate URL key: ${key}.`); - owners.set(key, name); - } - } - return variables; -} - -type ScalarVariableOptions = { - label?: string; - key: string; - defaultValue?: string; - history?: HistoryMode; -}; - -/** A local text filter. Typing replaces the current history entry by default. */ -export function textVariable({ - key, - label, - defaultValue = "", - history = "replace", -}: ScalarVariableOptions): AppVariable { - return { - kind: "text", - label: label ?? key, - urlKeys: [key], - defaultValue, - history, - read: (params) => params.get(key) ?? defaultValue, - write: (value) => ({ [key]: value === defaultValue ? null : value }), - valid: (value) => typeof value === "string", - same: (left, right) => left === right, - }; -} - -/** A single choice. Supply values when the option set is known before data loads. */ -export function selectVariable({ - key, - label, - defaultValue, - values, - history = "push", -}: ScalarVariableOptions & { - defaultValue: string; - values?: readonly string[]; -}): AppVariable { - if (values && !values.includes(defaultValue)) - throw new Error(`Select variable ${key} must include its default value.`); - const valid = (value: string) => typeof value === "string" && (!values || values.includes(value)); - return { - kind: "select", - label: label ?? key, - options: values?.map((id) => ({ id, label: id })), - urlKeys: [key], - defaultValue, - history, - read: (params) => { - const value = params.get(key); - return value !== null && valid(value) ? value : defaultValue; - }, - write: (value) => ({ [key]: value === defaultValue ? null : value }), - valid, - same: (left, right) => left === right, - }; -} - -export type DateRangeSelection = - | { kind: "preset"; id: DatePresetId; comparison?: "previous" } - | { kind: "dates"; start: string; end: string; comparison?: "previous" }; - -export type DateRangeVariableOptions = { - label?: string; - /** URL key for a relative preset; explicit dates use startKey and endKey. */ - key: string; - startKey?: string; - endKey?: string; - defaultValue: DateRangeSelection & { comparison?: never }; - contract: DateRangeContract; - history?: HistoryMode; - /** Opt into a URL-backed comparison with the preceding equal-length range. */ - comparison?: boolean; - comparisonKey?: string; -}; - -export type DateRangeVariable = AppVariable & { - kind: "dateRange"; - supportsComparison: boolean; - input: (selection: DateRangeSelection) => DateRangeRequest; - describeInput: (input: DateRangeRequest) => string; - bounds: () => { minDate?: string; maxDate: string; maxRangeDays: number; timeZone: string }; - resolve: (selection: DateRangeSelection) => DateRange; - previous: (selection: DateRangeSelection) => DateRange | null; - comparisonRange: (selection: DateRangeSelection) => DateRange | null; -}; - -const PRESET_IDS = new Set([ - "day", - "last-3", - "last-7", - "last-14", - "last-30", - "last-90", - "this-week", - "previous-week", - "this-month", - "previous-month", -]); - -/** A date variable keeps relative presets relative and validates exact dates against source coverage. */ -export function dateRangeVariable({ - key, - label = "Date range", - startKey = "start", - endKey = "end", - defaultValue, - contract, - history = "push", - comparison = false, - comparisonKey = "compare", -}: DateRangeVariableOptions): DateRangeVariable { - const bounds = contract.bounds; - function resolve(selection: DateRangeSelection): DateRange { - const limits = bounds(); - if (selection.kind === "preset") { - const preset = availableDatePresets(limits).find((item) => item.id === selection.id); - if (preset) return preset.range; - } else { - try { - return contract.parse(selection); - } catch { - // Invalid URL values fall back to the app default. - } - } - throw new Error(`Date variable ${key} is outside its available data range.`); - } - const same = (left: DateRangeSelection, right: DateRangeSelection) => - left.kind === right.kind && - left.comparison === right.comparison && - (left.kind === "preset" && right.kind === "preset" - ? left.id === right.id - : left.kind === "dates" && - right.kind === "dates" && - left.start === right.start && - left.end === right.end); - const variable: DateRangeVariable = { - kind: "dateRange", - label, - supportsComparison: comparison, - input: (selection) => - contract.request(resolve(selection), selection.comparison === "previous" && comparison), - describeInput: (input) => contract.describeInput(input.range), - urlKeys: comparison ? [key, startKey, endKey, comparisonKey] : [key, startKey, endKey], - defaultValue, - history, - bounds, - resolve, - previous: (selection) => (comparison ? contract.comparison(resolve(selection)) : null), - comparisonRange: (selection) => - selection.comparison === "previous" && comparison - ? contract.comparison(resolve(selection)) - : null, - same, - valid: (value) => { - try { - const range = resolve(value); - return ( - value.comparison === undefined || - (value.comparison === "previous" && comparison && contract.comparison(range) !== null) - ); - } catch { - return false; - } - }, - read: (params) => { - const withComparison = (selection: DateRangeSelection): DateRangeSelection => { - if (!comparison || params.get(comparisonKey) !== "previous") return selection; - const selected = { ...selection, comparison: "previous" as const }; - return variable.valid(selected) ? selected : selection; - }; - const presetId = params.get(key); - if (presetId && PRESET_IDS.has(presetId as DatePresetId)) { - const selection = { kind: "preset", id: presetId as DatePresetId } as const; - if (variable.valid(selection)) return withComparison(selection); - } - const start = params.get(startKey); - const end = params.get(endKey); - if (start && end) { - const selection = { kind: "dates", start, end } as const; - if (variable.valid(selection)) return withComparison(selection); - } - return withComparison(defaultValue); - }, - write: (value) => ({ - ...(same(value, defaultValue) - ? { [key]: null, [startKey]: null, [endKey]: null } - : value.kind === "preset" - ? { [key]: value.id, [startKey]: null, [endKey]: null } - : { [key]: null, [startKey]: value.start, [endKey]: value.end }), - ...(comparison ? { [comparisonKey]: value.comparison ?? null } : {}), - }), - }; - if (defaultValue.comparison) - throw new Error(`Date variable ${key} comparison must be activated by the reader.`); - if (!variable.valid(defaultValue)) - throw new Error(`Date variable ${key} has a default outside its available data range.`); - return variable; -} diff --git a/data-app/runtime/src/react/content.ts b/data-app/runtime/src/react/content.ts deleted file mode 100644 index f0af08a7..00000000 --- a/data-app/runtime/src/react/content.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { ReactNode } from "react"; -import type { DataReading, MetricReading, MetricValues } from "../core/reading.ts"; -import type { DateRangeRequest } from "../core/contract.ts"; - -export type DataContentHelpers = { - select: (select: (data: Data) => Value) => DataReading; - metric: (select: (data: Data) => MetricValues) => MetricReading; -}; - -export type DataContentState = DataContentHelpers & - ({ loading: true; data?: never; input?: never } | { loading: false; data: Data; input: Input }); - -/** Selectors run only for displayed data. Date comparisons inherit that result's input. */ -export function defineDataContent( - render: (state: DataContentState) => ReactNode, - options: { date?: (input: Input) => DateRangeRequest } = {}, -) { - return { - loading: render({ - loading: true, - select: () => ({ loading: true }), - metric: () => ({ loading: true }), - }), - children: (data: Data, input: Input) => - render({ - loading: false, - data, - input, - select: (select) => ({ loading: false, value: select(data) }), - metric: (select) => { - const values = select(data); - if (values.previous !== undefined && !options.date) - throw new Error("Metric comparisons require a view date binding."); - return { loading: false, value: { ...values, period: options.date?.(input) } }; - }, - }), - }; -} diff --git a/data-app/runtime/src/react/hooks.ts b/data-app/runtime/src/react/hooks.ts deleted file mode 100644 index 18f43796..00000000 --- a/data-app/runtime/src/react/hooks.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { useState, type ReactNode } from "react"; -import { keepPreviousData, hashKey, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { DataOperations } from "../core/contract.ts"; -import type { DataClient, InputOf, OutputOf } from "../client/index.ts"; -import { resolveDataView } from "../core/data-view.ts"; -import { reportingPeriodText, type ReportingPeriod } from "./ui/PeriodSummary.tsx"; -import { defineAppVariables, type VariableCollection } from "../core/variables.ts"; -import { defineDataContent, type DataContentState } from "./content.ts"; -import { describeViewInput, resolveViewInput, type DataViewDefinition } from "./view.ts"; -import { useViewVariables } from "./view-controls.tsx"; - -/** - * `placeholderData` may belong to an earlier input. Use `useDataView` to distinguish initial - * loading, refreshes, and changed-input requests. - */ -export function createDataHooks(client: DataClient) { - type QueryOptions = { - enabled?: boolean; - staleTime?: number; - gcTime?: number; - refetchOnWindowFocus?: boolean; - }; - - function useDataQuery( - name: Name, - input: InputOf, - options?: QueryOptions, - ) { - const queryClient = useQueryClient(); - const queryKey = ["data-operation", name, input] as const; - const query = useQuery({ - queryKey, - queryFn: ({ signal }) => client.query(name, input, { signal }), - placeholderData: keepPreviousData, - enabled: options?.enabled, - ...(options?.staleTime !== undefined && { staleTime: options.staleTime }), - ...(options?.gcTime !== undefined && { gcTime: options.gcTime }), - ...(options?.refetchOnWindowFocus !== undefined && { - refetchOnWindowFocus: options.refetchOnWindowFocus, - }), - }); - return { - ...query, - /** Abort the current request and restore its prior query state. */ - cancel: () => queryClient.cancelQueries({ queryKey, exact: true }), - }; - } - - /** Join a typed request with its displayed-data state. Pass the result to - * `DataApp.request` or `DataSection.result`. - * `isEmpty` is app-owned so a measured zero remains valid. Previous-input data - * stays labeled through refreshes and errors. Input equality uses React Query's - * stable key hash by default; override `sameInput` for a custom equivalence rule. */ - function useDataView( - name: Name, - input: InputOf, - options: QueryOptions & { - isEmpty: (data: OutputOf) => boolean; - describeInput?: (input: InputOf) => string; - period?: (input: InputOf) => ReportingPeriod; - sameInput?: (left: InputOf, right: InputOf) => boolean; - }, - ) { - const query = useDataQuery(name, input, options); - const [last, setLast] = useState<{ name: Name; response: NonNullable }>(); - if ( - query.isSuccess && - !query.isPlaceholderData && - query.data && - (last?.name !== name || last.response !== query.data) - ) { - setLast({ name, response: query.data }); - } - const sameInput = - options.sameInput ?? - ((left: InputOf, right: InputOf) => - hashKey([left]) === hashKey([right])); - const response = query.data ?? (last?.name === name ? last.response : undefined); - const snapshot = response && { data: response.data, input: response.input }; - const view = resolveDataView({ - requestedInput: input, - current: - snapshot && !query.isPlaceholderData && sameInput(snapshot.input, input) - ? snapshot - : undefined, - previous: snapshot, - pending: query.isFetching, - error: query.error instanceof Error ? query.error : undefined, - sameInput, - describe: - options.describeInput ?? - ((input) => (options.period ? reportingPeriodText(options.period(input)) : "this view")), - isEmpty: options.isEmpty, - }); - return { - ...query, - view, - response, - queries: response?.queries, - refresh: { - refreshing: query.isFetching, - onRefresh: () => void query.refetch(), - onCancel: query.cancel, - }, - }; - } - function defineDataView< - Name extends keyof Operations & string, - const Variables extends VariableCollection, - >( - definition: DataViewDefinition< - Name, - Variables, - InputOf, - OutputOf - >, - ) { - defineAppVariables(definition.variables); - return { - ...definition, - describeInput: describeViewInput>(definition), - content: ( - render: ( - state: DataContentState, InputOf>, - ) => ReactNode, - ) => defineDataContent(render, { date: definition.date?.input }), - }; - } - - function useView< - Name extends keyof Operations & string, - const Variables extends VariableCollection, - >( - definition: DataViewDefinition< - Name, - Variables, - InputOf, - OutputOf - >, - ) { - const variables = useViewVariables(definition.variables); - const request = useDataView( - definition.operation, - resolveViewInput(definition, variables.resolved), - { - ...definition, - describeInput: describeViewInput>(definition), - }, - ); - return { ...request, empty: definition.empty, controls: variables.controls, variables }; - } - return { useDataQuery, useDataView, defineDataView, useView }; -} diff --git a/data-app/runtime/src/react/index.ts b/data-app/runtime/src/react/index.ts deleted file mode 100644 index 9eaf6d05..00000000 --- a/data-app/runtime/src/react/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { mountDataApp, DataAppProvider } from "./mount.tsx"; -export { createDataHooks } from "./hooks.ts"; -export { defineDataContent } from "./content.ts"; -export type { DataContentState } from "./content.ts"; -export type { DataViewDefinition, ResolvedVariables } from "./view.ts"; -export * from "./ui/index.ts"; diff --git a/data-app/runtime/src/react/mount.tsx b/data-app/runtime/src/react/mount.tsx deleted file mode 100644 index 53ff2e56..00000000 --- a/data-app/runtime/src/react/mount.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useState, type ComponentType, type ReactNode } from "react"; -import { createRoot } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { dataAppTitle, type DataAppConfig } from "../core/config.ts"; - -/** Mount once per document; installs document identity and the shared request provider. */ -export function mountDataApp({ - config, - component: Component, - root = document.getElementById("root"), -}: { - config: DataAppConfig; - component: ComponentType; - root?: HTMLElement | null; -}): void { - if (!root) throw new Error("Data app root element is missing."); - document.documentElement.lang = navigator.language; - document.title = dataAppTitle(config); - createRoot(root).render( - - - , - ); -} - -export function DataAppProvider({ children }: { children: ReactNode }) { - const [client] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { retry: false, refetchOnWindowFocus: false, staleTime: 30_000 }, - }, - }), - ); - return {children}; -} diff --git a/data-app/runtime/src/react/ui/AboutData.css b/data-app/runtime/src/react/ui/AboutData.css deleted file mode 100644 index ede1a34b..00000000 --- a/data-app/runtime/src/react/ui/AboutData.css +++ /dev/null @@ -1,117 +0,0 @@ -.altertable-about-visual { - display: grid; - margin: 0 0 24px; - padding: 22px 20px 16px; - min-width: 0; - overflow-x: auto; - overscroll-behavior-x: none; - overflow-y: hidden; - border: 1px solid var(--at-border, #e3e6e8); - border-radius: var(--at-radius-surface, 12px); - background: var(--at-subtle, #f6f7f7); -} -.altertable-about-visual > * { - min-width: 0; -} -.altertable-about-visual > [role="img"], -.altertable-about-visual > svg { - display: flex; - align-items: flex-end; - min-height: 240px; - min-width: min-content; -} -.altertable-about-visual figure { - display: grid; - gap: 10px; - margin: 0; - padding: 0; -} -.altertable-about-visual figcaption { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - gap: 6px 12px; - color: var(--at-muted, #687078); - font-size: 12px; - line-height: 1.35; -} -.altertable-about-visual .altertable-metric-value { - margin: 0; - font-size: 36px; -} -.altertable-about-visual[data-kind="metric"] { - padding: 16px 18px; -} -.altertable-about-body { - display: grid; - gap: 28px; - min-width: 0; -} -.altertable-about-glossary-list { - display: grid; - gap: 16px; -} -.altertable-about-glossary-entry { - display: grid; - gap: 3px; -} -.altertable-about-glossary-entry > h3 { - margin: 0; - font-size: 14px; - font-weight: 650; - letter-spacing: -0.01em; -} -.altertable-about-glossary-entry > p { - margin: 0; - line-height: 1.55; -} -.altertable-about-tabs { - display: grid; - gap: 0; - min-width: 0; -} -.altertable-about-tabbar { - position: sticky; - top: -24px; - z-index: 2; - margin-top: -24px; - padding: 24px 0 14px; - background: var(--at-surface, #fff); -} -.altertable-about-tabs .react-aria-TabList { - display: flex; - flex-wrap: wrap; - gap: 2px; - width: 100%; - max-width: 100%; - padding: 3px; - border: 0; - border-radius: 10px; - background: var(--at-subtle, #f6f7f7); -} -.altertable-about-tabs .react-aria-TabPanels { - padding-top: 14px; -} -.altertable-about-tabs .react-aria-Tab { - margin: 0; - padding: 6px 10px; - border: 0; - border-radius: 8px; - font-size: 12px; - font-weight: 600; -} -.altertable-about-tabs .react-aria-Tab[data-selected] { - background: var(--at-surface, #fff); - color: var(--at-text, #202124); - box-shadow: 0 0 0 1px var(--at-border, #e3e6e8); -} -.altertable-about-tabs .react-aria-TabPanel { - min-width: 0; -} -@media (max-width: 600px) { - .altertable-about-tabbar { - top: -20px; - margin-top: -20px; - padding-top: 20px; - } -} diff --git a/data-app/runtime/src/react/ui/AboutData.tsx b/data-app/runtime/src/react/ui/AboutData.tsx deleted file mode 100644 index d7570701..00000000 --- a/data-app/runtime/src/react/ui/AboutData.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { - useEffect, - useRef, - useState, - type ComponentPropsWithRef, - type ReactNode, - type RefObject, -} from "react"; -import { useMergeRefs } from "@floating-ui/react"; -import { AppIcon } from "./icons.ts"; -import type { DisclosedQuery } from "../../core/contract.ts"; -import type { DataContext, GlossaryEntry } from "./data-context.ts"; -import { Button, type ButtonProps } from "./Button.tsx"; -import { EmptyState } from "./EmptyState.tsx"; -import { Kbd } from "./Kbd.tsx"; -import { QueryList } from "./QueryList.tsx"; -import { searchParams, slug, subscribeSearch, writeSearch } from "./search.ts"; -import { Sheet, type SheetDialogProps } from "./Sheet.tsx"; -import { Tabs, TabList, Tab, TabPanels, TabPanel } from "./Tabs.tsx"; -import { ariaKeyShortcuts, shortcuts, useShortcut } from "./shortcuts.ts"; -import { Tooltip } from "./Tooltip.tsx"; -import { useInspectionDefaults } from "./InspectionContext.tsx"; -import "./AboutData.css"; - -export type AboutTab = "glossary" | "queries"; -export type AboutEmpty = { - glossary: { title: string; description?: string }; - queries: { title: string; description?: string }; -}; - -export const defaultAboutEmpty: AboutEmpty = { - glossary: { title: "No glossary terms for this view" }, - queries: { title: "SQL is not available for this view" }, -}; - -export type AboutSubject = { - empty?: AboutEmpty; - id?: string; - title?: ReactNode; - description?: ReactNode; - visual?: ReactNode; - visualKind?: "metric" | "chart"; - dataContext?: DataContext; - glossaryEntry?: GlossaryEntry; - glossaryEntries?: GlossaryEntry[]; - glossaryIds?: string[]; - queries?: DisclosedQuery[]; - queryNames?: string[]; -}; - -export type AboutDataProps = AboutSubject & { - variant?: ButtonProps["variant"]; - iconOnly?: boolean; - /** - * Register the global inspect shortcut. Defaults on for existing toolbar triggers; local - * triggers set false. - */ - shortcut?: boolean; - tooltip?: ReactNode; - children?: ReactNode; - footer?: ReactNode; - portalRoot?: RefObject; - sheetProps?: SheetDialogProps; - open?: boolean; - onOpenChange?: (open: boolean) => void; - /** Initial inspect tab. While open, the URL's `tab` parameter tracks selection. */ - tab?: AboutTab; - trigger?: boolean; -} & Omit, "children" | "title">; - -function namedGlossaryEntries( - dataContext: DataContext | undefined, - ids: string[], -): GlossaryEntry[] { - return ids.map((id) => { - const entry = dataContext?.glossary[id]; - if (!entry) throw new Error(`Unknown glossary entry: ${id}.`); - return entry; - }); -} - -function listedGlossaryEntries({ - glossaryEntry, - glossaryEntries, - dataContext, - glossaryIds, -}: AboutSubject): GlossaryEntry[] { - if (glossaryEntries?.length) return glossaryEntries; - if (glossaryEntry) return [glossaryEntry]; - if (glossaryIds) return namedGlossaryEntries(dataContext, glossaryIds); - return Object.values(dataContext?.glossary ?? {}); -} - -function glossaryQueries(subject: AboutSubject): string[] | undefined { - if (subject.queryNames?.length) return subject.queryNames; - const names = listedGlossaryEntries(subject).flatMap((item) => item.queryNames ?? []); - return names.length ? names : undefined; -} - -function subjectId({ id, title }: AboutSubject): string { - if (id) return id; - return typeof title === "string" ? slug(title) : "data"; -} - -function GlossaryDetail({ entry }: { entry: GlossaryEntry }) { - return ( -
-

{entry.term}

- {entry.definition ?

{entry.definition}

: null} -
- ); -} - -function resolveTab(tab: string | null | undefined): AboutTab { - return tab === "queries" ? "queries" : "glossary"; -} - -/** - * Open state uses `?about=`; tab selection uses `?tab=`. Enable the global shortcut only on the - * page-level trigger. - */ -export function AboutData({ - id, - title, - description, - visual, - visualKind, - variant, - dataContext, - glossaryEntry, - glossaryEntries, - glossaryIds, - queries, - queryNames, - empty, - iconOnly = false, - shortcut = true, - tooltip, - children, - footer, - portalRoot, - sheetProps, - open: openProp, - onOpenChange, - tab, - trigger = true, - className, - onClick, - ref, - ...props -}: AboutDataProps) { - const defaults = useInspectionDefaults(); - const resolvedContext = dataContext ?? defaults?.dataContext; - const resolvedQueries = queries ?? defaults?.queries; - const resolvedEmpty = empty ?? defaults?.empty ?? defaultAboutEmpty; - const subject = { - id, - title, - description, - visual, - dataContext: resolvedContext, - glossaryEntry, - glossaryEntries, - glossaryIds, - queries: resolvedQueries, - queryNames, - empty: resolvedEmpty, - }; - const listed = listedGlossaryEntries(subject); - const names = glossaryQueries(subject); - if (names && resolvedQueries?.length) { - for (const name of names) { - if (!resolvedQueries.some((query) => query.name === name)) - throw new Error(`Unknown query name: ${name}.`); - } - } - const hasQueries = (resolvedQueries ?? []).some((query) => !names || names.includes(query.name)); - const sheetId = subjectId(subject); - const initialTab = resolveTab(tab ?? searchParams().get("tab")); - const [uncontrolledOpen, setUncontrolledOpen] = useState( - () => searchParams().get("about") === sheetId, - ); - const [selectedTab, setSelectedTab] = useState(initialTab); - const open = openProp ?? uncontrolledOpen; - const triggerRef = useRef(null); - const mergedTrigger = useMergeRefs([triggerRef, ref]); - const heading = title ?? "About the data"; - const summary = - description ?? resolvedContext?.description ?? "Glossary and queries for this view."; - const shortcutEnabled = trigger && shortcut && !props.disabled; - const hint = - tooltip ?? - (iconOnly && shortcutEnabled ? ( - <> - Explore data - - ) : ( - "Explore data" - )); - function setOpen(next: boolean) { - onOpenChange?.(next); - if (openProp === undefined) setUncontrolledOpen(next); - if (next) - writeSearch( - { about: sheetId, tab: selectedTab }, - searchParams().get("about") === sheetId ? "replace" : "push", - ); - else if (searchParams().get("about") === sheetId) writeSearch({ about: null, tab: null }); - } - function selectTab(next: string | number) { - const id = resolveTab(String(next)); - setSelectedTab(id); - if (open) writeSearch({ about: sheetId, tab: id }); - } - useEffect(() => { - function sync() { - const about = searchParams().get("about"); - const urlTab = searchParams().get("tab"); - if (about === sheetId) { - if (openProp === undefined) setUncontrolledOpen(true); - if (urlTab) setSelectedTab(resolveTab(urlTab)); - onOpenChange?.(true); - } else if (openProp === undefined) { - setUncontrolledOpen(false); - } - } - return subscribeSearch(sync); - }, [sheetId, openProp, onOpenChange]); - useShortcut(shortcuts.aboutData, () => setOpen(true), shortcutEnabled); - const triggerButton = ( - - ); - - return ( - <> - {trigger ? ( - - {triggerButton} - - ) : null} - - {visual && ( -
- {visual} -
- )} -
- -
- - Glossary - Queries - -
- - - {listed.length ? ( -
- {listed.map((entry) => ( - - ))} -
- ) : ( - - )} -
- - {hasQueries ? ( - - ) : ( - - )} - -
-
-
-
- - ); -} diff --git a/data-app/runtime/src/react/ui/AltertableLogo.tsx b/data-app/runtime/src/react/ui/AltertableLogo.tsx deleted file mode 100644 index 66d5e370..00000000 --- a/data-app/runtime/src/react/ui/AltertableLogo.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { SVGProps } from "react"; - -export type AltertableLogoProps = SVGProps; - -export function AltertableLogo(props: AltertableLogoProps) { - /* oxlint-disable jsx-a11y/prefer-tag-over-role -- Inline SVG needs an image role and accessible name. */ - return ( - - - - - ); - /* oxlint-enable jsx-a11y/prefer-tag-over-role */ -} diff --git a/data-app/runtime/src/react/ui/AppFooter.css b/data-app/runtime/src/react/ui/AppFooter.css deleted file mode 100644 index d5b72f61..00000000 --- a/data-app/runtime/src/react/ui/AppFooter.css +++ /dev/null @@ -1,44 +0,0 @@ -.altertable-app-footer { - color: var(--at-muted, #687078); - font: 12px/1.4 var(--at-font, ui-sans-serif, system-ui, sans-serif); -} -.altertable-app-footer-inner { - width: min(100% - 48px, var(--at-content-width, 1200px)); - margin: 0 auto; - padding-block: var(--at-space-lg, 24px); - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--at-space-md, 16px); -} -.altertable-app-footer-actions { - display: flex; - align-items: center; - min-width: 0; -} -.altertable-app-footer a { - display: inline-flex; - align-items: center; - gap: 4px; - color: inherit; - text-decoration: none; - white-space: nowrap; -} -.altertable-app-footer-logo { - width: 92px; - height: 16px; - margin-left: 4px; - color: var(--at-text, #202124); -} -.altertable-app-footer a:hover { - color: var(--at-text, #202124); -} -.altertable-app-footer a:focus-visible { - outline: 2px solid var(--at-accent, #4779dc); - outline-offset: 3px; -} -@media (max-width: 600px) { - .altertable-app-footer-inner { - width: min(100% - 32px, var(--at-content-width, 1200px)); - } -} diff --git a/data-app/runtime/src/react/ui/AppFooter.tsx b/data-app/runtime/src/react/ui/AppFooter.tsx deleted file mode 100644 index e6395502..00000000 --- a/data-app/runtime/src/react/ui/AppFooter.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { ComponentPropsWithRef, ReactNode } from "react"; -import { AltertableLogo } from "./AltertableLogo.tsx"; -import { classNames } from "./classNames.ts"; -import "./AppFooter.css"; - -export type AppFooterProps = ComponentPropsWithRef<"footer"> & { attribution?: ReactNode }; - -/** `children` fills the action slot; `attribution` replaces the default link. */ -export function AppFooter({ children, attribution, className, ...props }: AppFooterProps) { - return ( -
-
-
{children}
- {attribution === undefined ? ( - - Powered by - - - ) : ( - attribution - )} -
-
- ); -} diff --git a/data-app/runtime/src/react/ui/AppHeader.css b/data-app/runtime/src/react/ui/AppHeader.css deleted file mode 100644 index ed607767..00000000 --- a/data-app/runtime/src/react/ui/AppHeader.css +++ /dev/null @@ -1,67 +0,0 @@ -.altertable-app-header { - padding-bottom: 12px; - border-bottom: 1px solid var(--at-border, #e3e6e8); -} -.altertable-app-header-heading { - min-width: 0; -} -.altertable-app-header-primary { - display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: 4px 14px; - min-width: 0; -} -.altertable-app-header-context { - display: inline-flex; - align-items: baseline; - gap: 5px; - min-width: 0; -} -.altertable-app-header-connector { - color: var(--at-muted, #687078); - font-size: 11px; - font-weight: 600; - line-height: 1.2; -} -.altertable-app-header-primary h1 { - margin: 0; - font-family: var(--at-font-heading, inherit); - font-size: clamp(22px, 2.4vw, 28px); - font-weight: 680; - letter-spacing: -0.03em; - line-height: 1.15; - overflow-wrap: anywhere; - text-wrap: balance; -} -.altertable-app-header-heading > p { - max-width: 68ch; - margin: 4px 0 0; - color: var(--at-muted, #687078); - font-size: 13px; - line-height: 1.45; - text-wrap: pretty; -} -.altertable-app-header-toolbar { - flex: none; - align-self: center; - margin-inline-start: auto; -} -@media (max-width: 700px) { - .altertable-app-header-primary { - gap: 8px 12px; - } - .altertable-app-header-primary h1 { - flex-basis: 100%; - } - .altertable-app-header-context { - flex: 1 1 160px; - min-width: min(100%, 160px); - } - .altertable-app-header-toolbar { - flex: none; - } - .altertable-app-header-heading > p { - margin-top: 8px; - } -} diff --git a/data-app/runtime/src/react/ui/AppHeader.tsx b/data-app/runtime/src/react/ui/AppHeader.tsx deleted file mode 100644 index e7bf0e52..00000000 --- a/data-app/runtime/src/react/ui/AppHeader.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { ComponentPropsWithRef, ReactNode } from "react"; -import { classNames } from "./classNames.ts"; -import "./AppHeader.css"; - -export type AppHeaderProps = { - scope?: ReactNode; - title: ReactNode; - description?: ReactNode; - toolbar?: ReactNode; - children?: ReactNode; - headingProps?: Omit, "children">; -} & Omit, "children" | "title">; - -/** Page identity and actions; on narrow screens the scope shares a row with actions. */ -export function AppHeader({ - scope, - title, - description, - toolbar, - children, - headingProps, - className, - ...props -}: AppHeaderProps) { - return ( -
-
-
-

{title}

- {scope && ( -
- - {scope} -
- )} - {toolbar &&
{toolbar}
} -
- {description &&

{description}

} - {children} -
-
- ); -} diff --git a/data-app/runtime/src/react/ui/AppLayout.css b/data-app/runtime/src/react/ui/AppLayout.css deleted file mode 100644 index 8adabd1b..00000000 --- a/data-app/runtime/src/react/ui/AppLayout.css +++ /dev/null @@ -1,57 +0,0 @@ -html { - scrollbar-gutter: stable; -} -body, -#root { - margin: 0; -} -.altertable-app-layout { - display: flex; - flex-direction: column; - min-height: 100dvh; -} -.altertable-app-main { - display: grid; - align-content: start; - gap: var(--at-layout-gap, 24px); - min-width: 0; - width: min(100% - 48px, var(--at-content-width, 1200px)); - margin-inline: auto; - padding: var(--at-layout-gap, 24px) 0 var(--at-space-md, 16px); -} -.altertable-app-footer { - margin-top: auto; -} -.altertable-sr-only { - position: fixed; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - clip-path: inset(50%); - white-space: nowrap; - border: 0; -} -@media (max-width: 600px) { - .altertable-app-main { - width: min(100% - 32px, var(--at-content-width, 1200px)); - } -} -:root { - --at-content-width: 960px; - font-family: var(--at-font, ui-sans-serif, system-ui, sans-serif); - color: var(--at-text, #202124); - background: var(--at-background, #fff); - font-synthesis: none; - -webkit-font-smoothing: antialiased; - text-rendering: optimizeLegibility; -} -* { - box-sizing: border-box; -} -button { - font: inherit; - cursor: pointer; -} diff --git a/data-app/runtime/src/react/ui/AppLayout.tsx b/data-app/runtime/src/react/ui/AppLayout.tsx deleted file mode 100644 index 90c89b11..00000000 --- a/data-app/runtime/src/react/ui/AppLayout.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { ComponentProps, ComponentPropsWithRef, ReactNode } from "react"; -import { classNames } from "./classNames.ts"; -import { AppFooter } from "./AppFooter.tsx"; -import { TooltipProvider, type TooltipProviderProps } from "./Tooltip.tsx"; -import "./AppLayout.css"; - -export type AppLayoutProps = { - children: ReactNode; - footerActions?: ReactNode; - footer?: ReactNode; - footerProps?: Omit, "children">; - layoutProps?: Omit, "children">; - tooltipProviderProps?: Omit; -} & Omit, "children">; - -/** - * Native props target `
`. `layoutProps` targets the outer wrapper; `footer` replaces the - * default footer, while `footerActions` fills it. - */ -export function AppLayout({ - children, - footerActions, - footer, - footerProps, - layoutProps, - tooltipProviderProps, - className, - ...props -}: AppLayoutProps) { - return ( - -
-
- {children} -
- {footer === undefined ? {footerActions} : footer} -
-
- ); -} diff --git a/data-app/runtime/src/react/ui/AppScope.css b/data-app/runtime/src/react/ui/AppScope.css deleted file mode 100644 index e704dbe7..00000000 --- a/data-app/runtime/src/react/ui/AppScope.css +++ /dev/null @@ -1,28 +0,0 @@ -.altertable-app-scope { - display: inline-flex; - align-items: center; - gap: 5px; - max-width: 100%; - color: var(--at-muted, #687078); - font: 600 11px/1.2 var(--at-font, ui-sans-serif, system-ui, sans-serif); -} -.altertable-app-scope-organization { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--at-text, #202124); - font-size: 12px; - font-weight: 680; - letter-spacing: 0.06em; - line-height: 1.3; - text-transform: uppercase; -} -.altertable-app-scope-environment { - padding: 4px 8px; - border-radius: 999px; - background: var(--at-subtle, #f6f7f7); - color: var(--at-muted, #687078); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.01em; -} diff --git a/data-app/runtime/src/react/ui/AppScope.tsx b/data-app/runtime/src/react/ui/AppScope.tsx deleted file mode 100644 index d2b61f6b..00000000 --- a/data-app/runtime/src/react/ui/AppScope.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { ComponentPropsWithRef, ReactNode } from "react"; -import { classNames } from "./classNames.ts"; -import "./AppScope.css"; - -export type AppScopeProps = { - organization: string; - environment: string; - children?: ReactNode; -} & Omit, "children">; - -export function AppScope({ - organization, - environment, - children, - className, - ...props -}: AppScopeProps) { - return ( -
- {organization} - {environment} - {children} -
- ); -} diff --git a/data-app/runtime/src/react/ui/AppToolbar.css b/data-app/runtime/src/react/ui/AppToolbar.css deleted file mode 100644 index 37a0373e..00000000 --- a/data-app/runtime/src/react/ui/AppToolbar.css +++ /dev/null @@ -1,24 +0,0 @@ -.altertable-app-toolbar { - display: flex; - align-items: center; - justify-content: flex-end; - gap: var(--at-space-sm, 8px); - width: 100%; -} -.altertable-app-toolbar-updated { - margin-right: 6px; - color: color-mix(in srgb, var(--at-muted, #687078) 78%, var(--at-background, #fff)); - font-size: 11px; - white-space: nowrap; -} -.altertable-app-toolbar-actions { - display: flex; - align-items: center; - justify-content: flex-end; - flex-wrap: wrap; - gap: var(--at-space-xs, 4px); - margin-inline-start: auto; -} -.altertable-app-toolbar { - flex-shrink: 0; -} diff --git a/data-app/runtime/src/react/ui/AppToolbar.tsx b/data-app/runtime/src/react/ui/AppToolbar.tsx deleted file mode 100644 index 3e6b3e2d..00000000 --- a/data-app/runtime/src/react/ui/AppToolbar.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import type { ComponentProps, ComponentPropsWithRef, ReactNode } from "react"; -import type { DataView } from "../../core/data-view.ts"; -import { LiveControl, type LiveControlProps } from "./LiveControl.tsx"; -import { AppIcon } from "./icons.ts"; -import { IconButton } from "./IconButton.tsx"; -import { PlayStory } from "./PlayStory.tsx"; -import { RefreshControl } from "./RefreshControl.tsx"; -import { classNames } from "./classNames.ts"; -import { shortcuts, useShortcut } from "./shortcuts.ts"; -import "./AppToolbar.css"; - -export type AppToolbarProps = { - children?: ReactNode; - end?: ReactNode; - controlsProps?: Omit, "children">; - updatedAt?: ReactNode; - initialLoading?: boolean; - requestState?: DataView["kind"]; - refresh?: { - refreshing: boolean; - onRefresh: () => void; - onCancel?: () => void; - label?: string; - tooltip?: ReactNode; - buttonProps?: Omit, "children" | "onClick">; - statusProps?: ComponentProps["statusProps"]; - }; - live?: LiveControlProps; - aboutData?: ReactNode; - story?: ComponentProps; -} & Omit, "children">; - -/** Header actions only. Put reader-controlled inputs in DataApp.variables below the header. - * Observation time, or an initial loading status, sits before Refresh. Children prepend app controls in the right - * cluster; end follows the built-in actions. Live sits beside Refresh when supplied. Refresh also - * answers Alt/Option+R and uses the button to cancel a running request when onCancel is provided; - * refresh.tooltip overrides its static label. All built-in controls are optional. */ -export function AppToolbar({ - children, - updatedAt, - initialLoading = false, - requestState, - refresh, - live, - aboutData, - story, - end, - controlsProps, - className, - role = "group", - "aria-label": ariaLabel = "Page actions", - ...props -}: AppToolbarProps) { - const refreshLabel = - refresh?.refreshing && refresh.onCancel - ? "Cancel refresh" - : (refresh?.buttonProps?.["aria-label"] ?? "Refresh data"); - function runRefresh() { - if (refresh && !refresh.refreshing) refresh.onRefresh(); - } - function activateRefresh() { - if (!refresh) return; - if (refresh.refreshing) refresh.onCancel?.(); - else refresh.onRefresh(); - } - useShortcut(shortcuts.refresh, runRefresh, !!refresh); - return ( -
-
- {children} - {(initialLoading || updatedAt) && ( - - {initialLoading ? Loading data : updatedAt} - - )} - {refresh && ( - - - - - {refresh.onCancel && ( - - )} - - - )} - {live && } - {aboutData} - {story && } - {end} -
-
- ); -} diff --git a/data-app/runtime/src/react/ui/Breakdown.css b/data-app/runtime/src/react/ui/Breakdown.css deleted file mode 100644 index 7e606d27..00000000 --- a/data-app/runtime/src/react/ui/Breakdown.css +++ /dev/null @@ -1,86 +0,0 @@ -.altertable-breakdown { - display: grid; - gap: 12px; - min-width: 0; -} -.altertable-breakdown-summary { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; - color: var(--at-muted, #687078); - font-size: 12px; -} -.altertable-breakdown-summary strong { - flex: none; - color: var(--at-text, #202124); - font-size: 13px; - font-variant-numeric: tabular-nums; - font-weight: 650; -} -.altertable-breakdown-bar { - display: flex; - height: 12px; - overflow: hidden; - border-radius: 999px; - background: var(--at-subtle, #f6f7f7); -} -.altertable-breakdown[data-dominant="true"] .altertable-breakdown-bar { - height: 8px; -} -.altertable-breakdown-bar > span { - display: block; - height: 100%; - flex: none; -} -.altertable-breakdown-list { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr)); - gap: 6px 18px; - margin: 0; - min-width: 0; -} -.altertable-breakdown-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - align-items: baseline; - column-gap: 8px; - min-width: 0; - padding: 8px 0; - border-bottom: 1px solid var(--at-border, #e3e6e8); - font-size: 13px; -} -.altertable-breakdown-row dt { - display: flex; - align-items: baseline; - gap: 7px; - min-width: 0; - overflow-wrap: anywhere; - color: var(--at-text, #202124); -} -.altertable-breakdown-row dd { - margin: 0; - font-variant-numeric: tabular-nums; - text-align: end; - white-space: nowrap; -} -.altertable-breakdown-swatch { - display: inline-block; - width: 8px; - height: 8px; - flex: none; - border-radius: 2px; - background: var(--altertable-breakdown-color); -} -.altertable-breakdown-row[data-empty="true"] .altertable-breakdown-swatch { - border: 1px solid var(--at-border, #e3e6e8); - background: transparent; -} -.altertable-breakdown-value { - color: var(--at-text, #202124); - font-weight: 650; -} -.altertable-breakdown-share { - min-width: 4ch; - color: var(--at-muted, #687078); -} diff --git a/data-app/runtime/src/react/ui/Breakdown.tsx b/data-app/runtime/src/react/ui/Breakdown.tsx deleted file mode 100644 index d1ad3fec..00000000 --- a/data-app/runtime/src/react/ui/Breakdown.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import type { ComponentPropsWithRef, CSSProperties, ReactNode } from "react"; -import { formatCount, formatPercent } from "../../core/format.ts"; -import { classNames } from "./classNames.ts"; -import { chartColor } from "./chartColor.ts"; -import "./Breakdown.css"; - -export type BreakdownItem = { id: string; label: ReactNode; value: number }; -export type BreakdownProps = { - total: number; - items: readonly BreakdownItem[]; - formatValue?: (value: number) => ReactNode; -} & Omit, "children">; - -/** Mutually exclusive parts of one observed whole. Every share uses the supplied total. */ -export function Breakdown({ - total, - items, - formatValue = formatCount, - className, - ...props -}: BreakdownProps) { - if ( - !Number.isFinite(total) || - total < 0 || - items.some((item) => !Number.isFinite(item.value) || item.value < 0) || - items.reduce((sum, item) => sum + item.value, 0) > total - ) - throw new Error("Breakdown items must be nonnegative parts of the observed total."); - const dominant = total > 0 && items.some((item) => item.value / total >= 0.85); - return ( -
-
- Share of total - {formatValue(total)} total -
- -
- {items.map((item) => { - const share = total ? item.value / total : 0; - const color = { - "--altertable-breakdown-color": chartColor(item.id), - } as CSSProperties; - return ( -
-
-
-
{formatValue(item.value)}
-
{formatPercent(share)}
-
- ); - })} -
-
- ); -} diff --git a/data-app/runtime/src/react/ui/Button.css b/data-app/runtime/src/react/ui/Button.css deleted file mode 100644 index 80e10fbe..00000000 --- a/data-app/runtime/src/react/ui/Button.css +++ /dev/null @@ -1,60 +0,0 @@ -.altertable-button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - min-height: 38px; - padding: 0 12px; - border: 1px solid var(--at-border, #e3e6e8); - border-radius: var(--at-radius-control, 7px); - background: var(--at-surface, #fff); - color: var(--at-text, #202124); - font: inherit; - font-size: 13px; - font-weight: 600; - cursor: pointer; - transition: - background-color 120ms ease, - border-color 120ms ease, - color 120ms ease; - -webkit-tap-highlight-color: transparent; -} -.altertable-button:hover:not(:disabled) { - background: var(--at-subtle, #f6f7f7); -} -.altertable-button[data-size="icon"] { - flex: none; - width: 36px; - min-width: 36px; - height: 36px; - min-height: 36px; - padding: 0; -} -.altertable-button[data-variant="outline"] { - box-shadow: none; -} -.altertable-button[data-variant="elevated"] { - box-shadow: var(--at-shadow-surface, 0 3px 16px rgb(20 28 40 / 5%)); -} -.altertable-button[data-variant="ghost"] { - border-color: transparent; - background: transparent; - box-shadow: none; -} -.altertable-button[data-variant="ghost"]:hover:not(:disabled), -.altertable-button[data-variant="ghost"][data-open="true"] { - background: var(--at-subtle, #f6f7f7); -} -.altertable-button:focus-visible { - outline: 2px solid var(--at-accent, #4779dc); - outline-offset: 2px; -} -.altertable-button:disabled { - opacity: 0.5; - cursor: default; -} -@media (prefers-reduced-motion: reduce) { - .altertable-button { - transition: none; - } -} diff --git a/data-app/runtime/src/react/ui/Button.tsx b/data-app/runtime/src/react/ui/Button.tsx deleted file mode 100644 index 5897ebc8..00000000 --- a/data-app/runtime/src/react/ui/Button.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { ComponentPropsWithRef } from "react"; -import { classNames } from "./classNames.ts"; -import "./Button.css"; - -export type ButtonProps = ComponentPropsWithRef<"button"> & { - variant?: "elevated" | "outline" | "ghost"; - size?: "default" | "icon"; -}; - -export function Button({ - className, - type = "button", - variant = "outline", - size = "default", - ...props -}: ButtonProps) { - return ( - - )} - - - - ( - - {loading ? loadingMessage : emptyMessage} - - )} - > - {(hit) => ( - - - {hit.item.description && ( - - - - )} - - )} - - - - ); -} diff --git a/data-app/runtime/src/react/ui/ComparisonVisual.css b/data-app/runtime/src/react/ui/ComparisonVisual.css deleted file mode 100644 index f59bfd06..00000000 --- a/data-app/runtime/src/react/ui/ComparisonVisual.css +++ /dev/null @@ -1,109 +0,0 @@ -.altertable-comparison-visual { - display: grid; - gap: 24px; - width: 100%; - margin: 0; -} -.altertable-comparison-visual figcaption { - color: var(--at-muted, #687078); - font-size: 13px; - font-weight: 650; -} -.altertable-comparison-row { - display: grid; - gap: 9px; -} -.altertable-comparison-row > div { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; -} -.altertable-comparison-row > div > span { - color: var(--at-muted, #687078); - font-size: 13px; -} -.altertable-comparison-row strong { - color: var(--at-text, #202124); - font-family: var(--at-font-heading, inherit); - font-size: clamp(22px, 3vw, 32px); - font-variant-numeric: tabular-nums; - letter-spacing: -0.04em; -} -.altertable-comparison-track { - display: block; - width: 100%; - height: 10px; - overflow: hidden; - border-radius: 999px; - background: var(--at-subtle, #f6f7f7); -} -.altertable-comparison-track > span { - display: block; - min-width: 2px; - height: 100%; - border-radius: inherit; - background: var(--at-accent, #405d47); -} -.altertable-comparison-row[data-period="previous"] .altertable-comparison-track > span { - background: var(--at-muted, #687078); - opacity: 0.45; -} -.altertable-comparison-change { - display: inline-flex; - align-items: center; - gap: 5px; - margin: 0; - color: var(--at-muted, #687078); - font-size: 13px; - font-weight: 600; - font-variant-numeric: tabular-nums; -} -.altertable-comparison-change[data-tone="good"] { - color: var(--at-accent, #405d47); -} -.altertable-comparison-change[data-tone="bad"] { - color: var(--at-danger, #b42318); -} -.altertable-about-visual .altertable-comparison-visual { - gap: 12px; -} -.altertable-about-visual .altertable-comparison-row { - gap: 5px; -} -.altertable-comparison-visual[data-emphasis="story"] { - gap: clamp(22px, 4vh, 38px); -} -.altertable-comparison-visual[data-emphasis="story"] figcaption { - font-size: 14px; -} -.altertable-comparison-visual[data-emphasis="story"] - .altertable-comparison-row[data-period="current"] - > div { - align-items: flex-start; - flex-direction: column-reverse; - gap: 10px; -} -.altertable-comparison-visual[data-emphasis="story"] - .altertable-comparison-row[data-period="current"] - strong { - font-size: clamp(48px, 7vw, 88px); - line-height: 1; -} -.altertable-comparison-visual[data-emphasis="story"] - .altertable-comparison-row[data-period="previous"] - strong { - font-size: clamp(22px, 2.6vw, 30px); -} -.altertable-comparison-visual[data-emphasis="story"] .altertable-comparison-track { - height: 12px; -} -.altertable-comparison-visual[data-emphasis="story"] .altertable-comparison-change { - font-size: 14px; -} -.altertable-about-visual - .altertable-comparison-visual[data-emphasis="story"] - .altertable-comparison-row[data-period="current"] - strong { - font-size: 36px; -} diff --git a/data-app/runtime/src/react/ui/ComparisonVisual.tsx b/data-app/runtime/src/react/ui/ComparisonVisual.tsx deleted file mode 100644 index f1adae7b..00000000 --- a/data-app/runtime/src/react/ui/ComparisonVisual.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { AppIcon } from "./icons.ts"; -import { comparisonChange, type MetricComparison } from "./comparison.ts"; -import type { MetricDefinition } from "./metric.ts"; -import { metricComparison } from "./metric.ts"; -import type { MetricReading } from "../../core/reading.ts"; -import { ContentSkeleton } from "./ContentSkeleton.tsx"; -import "./ComparisonVisual.css"; - -type UnboundComparisonProps = MetricComparison & { - label: string; - emphasis?: "standard" | "story"; -}; - -export type ComparisonVisualProps = - | UnboundComparisonProps - | { - metric: MetricDefinition; - reading: MetricReading; - emphasis?: "standard" | "story"; - }; - -export function ComparisonVisual(props: ComparisonVisualProps) { - if ("metric" in props) { - if (props.reading.loading) return ; - const comparison = metricComparison(props.metric, props.reading); - if (!comparison) return null; - return ( - - ); - } - return ; -} - -function ComparisonContent({ - label, - current, - previous, - favorableDirection, - emphasis = "standard", -}: UnboundComparisonProps) { - const max = Math.max(current.value, previous?.value ?? 0, 1); - const { percent, tone, icon } = comparisonChange({ current, previous, favorableDirection }); - return ( -
-
{label}
-
-
- {current.periodLabel ?? "Current period"} - {current.formattedValue} -
- - - -
- {previous && ( -
-
- {previous.periodLabel ?? "Previous period"} - {previous.formattedValue} -
- - - -
- )} -

- {percent === null ? ( - "No comparable previous value" - ) : ( - <> - - {Math.abs(percent).toFixed(1)}% vs{" "} - {previous?.periodLabel?.toLowerCase() ?? "previous period"} - - )} -

-
- ); -} diff --git a/data-app/runtime/src/react/ui/ContentSkeleton.css b/data-app/runtime/src/react/ui/ContentSkeleton.css deleted file mode 100644 index 14ac9345..00000000 --- a/data-app/runtime/src/react/ui/ContentSkeleton.css +++ /dev/null @@ -1,53 +0,0 @@ -.altertable-content-skeleton { - display: grid; - align-content: start; - gap: 14px; - min-width: 0; - padding: 24px; -} -.altertable-content-skeleton-label { - width: 38%; - height: 15px; -} -.altertable-content-skeleton-value { - width: 56%; - height: 38px; -} -.altertable-content-skeleton-chart { - width: 100%; - height: 180px; -} -.altertable-content-skeleton-foot { - width: 70%; - height: 12px; -} -.altertable-content-skeleton-subtitle { - width: 52%; - height: 12px; -} -.altertable-content-skeleton-rows { - display: grid; - gap: 18px; - margin-top: 12px; -} -.altertable-content-skeleton-row { - display: grid; - gap: 8px; -} -.altertable-content-skeleton-row-label { - width: 56%; - height: 14px; -} -.altertable-content-skeleton-row-track { - width: 100%; - height: 8px; -} -.altertable-content-skeleton-row:nth-child(2) .altertable-content-skeleton-row-track { - width: 72%; -} -.altertable-content-skeleton-row:nth-child(3) .altertable-content-skeleton-row-track { - width: 48%; -} -.altertable-content-skeleton-row:nth-child(4) .altertable-content-skeleton-row-track { - width: 30%; -} diff --git a/data-app/runtime/src/react/ui/ContentSkeleton.tsx b/data-app/runtime/src/react/ui/ContentSkeleton.tsx deleted file mode 100644 index aa0452ae..00000000 --- a/data-app/runtime/src/react/ui/ContentSkeleton.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { ComponentPropsWithRef } from "react"; -import { classNames } from "./classNames.ts"; -import { Skeleton } from "./Skeleton.tsx"; -import "./ContentSkeleton.css"; - -export type ContentSkeletonProps = { - rows?: number; - variant: "metric" | "panel" | "ranking"; -} & Omit, "children">; - -/** A metric, panel, or ranking placeholder to compose in the same layout as live content. */ -export function ContentSkeleton({ variant, rows = 4, className, ...props }: ContentSkeletonProps) { - if (!Number.isInteger(rows) || rows < 0 || rows > 100) - throw new Error("Skeleton rows must be between 0 and 100."); - return ( - - ); -} diff --git a/data-app/runtime/src/react/ui/DataApp.tsx b/data-app/runtime/src/react/ui/DataApp.tsx deleted file mode 100644 index 1e89e132..00000000 --- a/data-app/runtime/src/react/ui/DataApp.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { useState, type ComponentProps, type ReactNode } from "react"; -import { createThemeController } from "../../core/appearance.ts"; -import type { DisclosedQuery } from "../../core/contract.ts"; -import type { DataAppConfig } from "../../core/config.ts"; -import type { DataView } from "../../core/data-view.ts"; -import { AboutData, type AboutEmpty } from "./AboutData.tsx"; -import { AppHeader } from "./AppHeader.tsx"; -import { AppLayout } from "./AppLayout.tsx"; -import { AppScope } from "./AppScope.tsx"; -import { AppToolbar, type AppToolbarProps } from "./AppToolbar.tsx"; -import type { DataContext } from "./data-context.ts"; -import type { PlayStoryProps } from "./PlayStory.tsx"; -import { ThemeToggle } from "./ThemeSelector.tsx"; -import { VariableBar } from "./VariableBar.tsx"; -import { DataViewToast } from "./DataViewToast.tsx"; -import { InspectionContext } from "./InspectionContext.tsx"; -import { DataSection } from "./DataSection.tsx"; -import type { EmptyStateProps } from "./EmptyState.tsx"; - -type DataAppBaseProps = { - config: DataAppConfig; - dataContext: DataContext; - aboutEmpty?: AboutEmpty; - description?: ReactNode; - /** Display names only; config.scope remains the connection identity. */ - scopeLabels?: { organization?: string; environment?: string }; - variables?: ReactNode; - story?: Omit & - Partial>; - toolbarActions?: ReactNode; - footerActions?: ReactNode; - layoutProps?: Omit, "children" | "footerActions">; -}; - -export type DataAppRequest = { - view: DataView; - refetch: () => unknown; - queries?: DisclosedQuery[]; - refresh?: AppToolbarProps["refresh"]; - empty?: Pick; - controls?: ReactNode; -}; - -export type DataAppProps = DataAppBaseProps & - ( - | ({ - request: DataAppRequest; - children: (data: Data, displayedInput: Input) => ReactNode; - loading?: ReactNode; - empty?: Pick; - label?: string; - queries?: never; - refresh?: never; - } & ( - | { - request: DataAppRequest & { - empty: Pick; - }; - } - | { empty: Pick } - )) - | { - request?: never; - children: ReactNode; - queries?: DisclosedQuery[]; - refresh?: AppToolbarProps["refresh"]; - loading?: never; - empty?: never; - label?: never; - } - ); - -/** The primary request owns the page's result, period, refresh status, and inspection context. - * Without a request, the shell accepts authored children for setup or static views. */ -export function DataApp(props: DataAppProps) { - const { - config, - dataContext, - aboutEmpty, - description, - scopeLabels, - request, - queries, - refresh, - variables, - story, - toolbarActions, - footerActions, - layoutProps, - } = props; - const [theme] = useState(() => createThemeController(config.appearance)); - const scope = ( - - ); - return ( - - }> - - } - > - {toolbarActions} - - } - /> - {(variables ?? request?.controls) && ( - {variables ?? request?.controls} - )} - {request ? ( - - {(data, displayedInput) => props.children(data, displayedInput)} - - ) : ( - props.children - )} - {request && void request.refetch()} />} - - - ); -} diff --git a/data-app/runtime/src/react/ui/DataBoundary.css b/data-app/runtime/src/react/ui/DataBoundary.css deleted file mode 100644 index 12139d44..00000000 --- a/data-app/runtime/src/react/ui/DataBoundary.css +++ /dev/null @@ -1,63 +0,0 @@ -.altertable-data-boundary { - min-width: 0; -} -.altertable-data-boundary-notice { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; - min-height: 44px; - margin: 0 0 14px; - padding: 8px 12px; - border: 1px solid var(--at-border, #e3e6e8); - border-radius: var(--at-radius-control, 8px); - color: var(--at-muted, #687078); - background: var(--at-subtle, #f6f7f7); - font-size: 12px; - line-height: 1.45; -} -.altertable-data-boundary-notice[data-state="stale-error"] { - border-color: color-mix(in srgb, var(--at-danger, #b42318) 30%, var(--at-border, #e3e6e8)); -} -.altertable-data-boundary-notice[data-state="stale-error"] > svg { - flex: none; - color: var(--at-danger, #b42318); -} -.altertable-data-boundary-message { - flex: 1 1 240px; -} -.altertable-data-boundary-action { - margin-left: auto; -} -.altertable-data-boundary-action button { - min-height: 32px; -} -.altertable-data-boundary-spinner { - flex: none; - animation: altertable-data-boundary-spin 1s linear infinite; -} -@keyframes altertable-data-boundary-spin { - to { - transform: rotate(360deg); - } -} -.altertable-data-boundary-content { - transition: opacity 180ms ease; -} -.altertable-data-boundary-content[data-updating="true"] { - opacity: 0.58; - transition: opacity 180ms ease 450ms; -} -.altertable-data-boundary-content[data-stale-error="true"] { - opacity: 0.58; -} -@media (prefers-reduced-motion: reduce) { - .altertable-data-boundary-content, - .altertable-data-boundary-content[data-updating="true"], - .altertable-data-boundary-content[data-stale-error="true"] { - transition: none; - } - .altertable-data-boundary-spinner { - animation: none; - } -} diff --git a/data-app/runtime/src/react/ui/DataBoundary.tsx b/data-app/runtime/src/react/ui/DataBoundary.tsx deleted file mode 100644 index 5d60cb53..00000000 --- a/data-app/runtime/src/react/ui/DataBoundary.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import type { ComponentPropsWithRef, ReactNode } from "react"; -import { classNames } from "./classNames.ts"; -import { AppIcon } from "./icons.ts"; -import "./DataBoundary.css"; - -import type { DataView } from "../../core/data-view.ts"; - -export type DataBoundaryProps = { - view: DataView; - loading: ReactNode; - empty: ReactNode; - error: (error: Error) => ReactNode; - staleError?: (error: Error) => ReactNode; - notice?: "inline" | "none"; - dimOnUpdate?: boolean; - children: (data: T, displayedInput: Input) => ReactNode; -} & Omit, "children">; - -/** Render one request state at a time. Prior content remains readable during an update. - * Local boundaries can opt into an inline notice and delayed dimming. */ -export function DataBoundary({ - view, - loading, - empty, - error, - staleError, - notice = "none", - dimOnUpdate = false, - children, - className, - ...props -}: DataBoundaryProps) { - if (view.kind === "loading" || view.kind === "empty" || view.kind === "error") - return ( -
- {view.kind === "loading" ? loading : view.kind === "empty" ? empty : error(view.error)} -
- ); - - const updating = view.kind === "updating"; - const hasStaleError = view.kind === "stale-error"; - return ( -
- {notice === "inline" && (updating || hasStaleError) && ( -
- {updating ? ( - - ) : ( - - )} - {view.message} - {staleError && view.kind === "stale-error" && ( - {staleError(view.error)} - )} -
- )} -
- {children(view.data, view.kind === "ready" ? view.input : view.displayedInput)} -
-
- ); -} diff --git a/data-app/runtime/src/react/ui/DataPanel.css b/data-app/runtime/src/react/ui/DataPanel.css deleted file mode 100644 index b36b05fa..00000000 --- a/data-app/runtime/src/react/ui/DataPanel.css +++ /dev/null @@ -1,95 +0,0 @@ -.altertable-data-panel { - min-width: 0; - border: 1px solid var(--at-border, #e3e6e8); - border-radius: calc(var(--at-radius-surface, 12px) * 1.5); - corner-shape: squircle; - background: var(--at-surface, #fff); - box-shadow: var(--at-shadow-surface, none); -} -.altertable-data-panel-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--at-space-md, 16px); - padding: var(--at-layout-gap, 24px) var(--at-layout-gap, 24px) 0; -} -.altertable-data-panel-header > div:first-child { - flex: 1; - min-width: 0; -} -.altertable-data-panel-help { - display: flex; - flex: none; - align-items: flex-start; -} -.altertable-data-panel-header h2 { - margin: 0; - color: var(--at-text, #202124); - font-family: var(--at-font-heading, inherit); - font-size: 17px; - font-weight: 650; - line-height: 1.3; - letter-spacing: -0.02em; -} -.altertable-data-panel-count { - margin-left: 8px; - color: var(--at-muted, #687078); - font-size: 12px; - font-weight: 500; - letter-spacing: 0; - white-space: nowrap; -} -.altertable-data-panel-header p { - margin: 4px 0 0; - color: var(--at-muted, #687078); - font-size: 12px; - line-height: 1.45; -} -.altertable-data-panel-body { - min-width: 0; - padding: var(--at-space-md, 16px) var(--at-layout-gap, 24px) var(--at-layout-gap, 24px); -} -.altertable-data-panel-body[data-padding="flush"] { - padding: 0; -} -.altertable-data-panel-footer { - padding: var(--at-space-md, 16px) var(--at-layout-gap, 24px); -} -.altertable-data-panel-body > .altertable-status-panel { - margin: 0; - border: 0; - border-radius: 0; - background: transparent; - padding: 0; -} -.altertable-data-panel-status { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 6px; - margin-top: 8px; - color: var(--at-muted, #687078); - font-size: 12px; - line-height: 1.4; -} -.altertable-data-panel-status[data-state="error"] svg { - color: var(--at-danger, #b42318); -} -.altertable-data-panel-status .altertable-button { - min-height: 28px; - padding: 0 6px; - font-size: 12px; -} -.altertable-data-panel-status-spinner { - animation: altertable-data-panel-spin 1s linear infinite; -} -@keyframes altertable-data-panel-spin { - to { - transform: rotate(360deg); - } -} -@media (prefers-reduced-motion: reduce) { - .altertable-data-panel-status-spinner { - animation: none; - } -} diff --git a/data-app/runtime/src/react/ui/DataPanel.tsx b/data-app/runtime/src/react/ui/DataPanel.tsx deleted file mode 100644 index b9a4a24d..00000000 --- a/data-app/runtime/src/react/ui/DataPanel.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useId, type ComponentPropsWithRef, type ReactNode } from "react"; -import { formatCount } from "../../core/format.ts"; -import { AppIcon } from "./icons.ts"; -import { AboutData, type AboutSubject } from "./AboutData.tsx"; -import { Button } from "./Button.tsx"; -import { classNames } from "./classNames.ts"; -import { EmptyState, type EmptyStateProps } from "./EmptyState.tsx"; -import "./Inspect.css"; -import "./DataPanel.css"; - -export type DataPanelAbout = Omit; - -export type DataPanelProps = { - title: ReactNode; - - count?: number; - description?: ReactNode; - about?: DataPanelAbout; - action?: ReactNode; - status?: { kind: "updating" | "error"; message: string; onRetry?: () => void }; - footer?: ReactNode; - /** Valid result with no data to visualize. Supersedes children, including in the inspect sheet. */ - empty?: Pick; - - bodyPadding?: "inset" | "flush"; - children: ReactNode; -} & Omit, "about" | "title" | "children">; - -/** - * `about` opens inspection for this panel. Help stays visible on touch and appears on hover or - * focus with fine pointers. - */ -export function DataPanel({ - title, - count, - description, - about, - action, - status, - footer, - empty, - bodyPadding = "inset", - children, - className, - ...props -}: DataPanelProps) { - const titleId = useId(); - const content = empty ? : children; - const help = about && ( - - - - ); - return ( -
-
-
-

- {title} - {count !== undefined && ( - {formatCount(count)} - )} -

- {description &&

{description}

} - {status && ( -
- {status.kind === "error" ? ( - - ) : ( - - )} - {status.message} - {status.onRetry && ( - - )} -
- )} -
- {(help || action) && ( -
- {action} - {help} -
- )} -
-
- {content} -
- {footer &&
{footer}
} -
- ); -} diff --git a/data-app/runtime/src/react/ui/DataSection.tsx b/data-app/runtime/src/react/ui/DataSection.tsx deleted file mode 100644 index 3347e791..00000000 --- a/data-app/runtime/src/react/ui/DataSection.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import type { ComponentPropsWithRef, ReactNode } from "react"; -import { DataAppError } from "../../client/index.ts"; -import { Button } from "./Button.tsx"; -import { ContentSkeleton } from "./ContentSkeleton.tsx"; -import { DataBoundary } from "./DataBoundary.tsx"; -import type { DataView } from "../../core/data-view.ts"; -import { EmptyState, type EmptyStateProps } from "./EmptyState.tsx"; -import { StatusPanel } from "./StatusPanel.tsx"; - -function errorPresentation(cause: Error) { - const code = cause instanceof DataAppError ? cause.code : "request_failed"; - switch (code) { - case "source_unauthorized": - return { - title: "Connection needs attention", - description: "Ask the app owner to reconnect the lakehouse.", - retryable: false, - }; - case "source_forbidden": - return { - title: "Data access denied", - description: "The selected profile cannot read this data. Ask the app owner for access.", - retryable: false, - }; - case "source_query_rejected": - return { - title: "This view cannot run its query", - description: "Ask the app owner to check the data operation.", - retryable: false, - }; - case "source_rate_limited": - return { - title: "Data source is busy", - description: "The lakehouse is handling too many requests.", - retryable: true, - }; - case "timeout": - return { - title: "Request timed out", - description: "The lakehouse took too long to respond.", - retryable: true, - }; - case "source_unavailable": - return { - title: "Couldn’t load results", - description: "The lakehouse isn’t responding.", - retryable: true, - }; - default: - return { - title: "Couldn’t load results", - description: "The data request failed.", - retryable: true, - }; - } -} - -type SectionEmpty = Pick; -type SectionResult = { - view: DataView; - refetch: () => unknown; - empty?: SectionEmpty; -}; - -export type DataSectionProps = ( - | { view: DataView; result?: never; empty: SectionEmpty } - | { result: SectionResult; view?: never; empty: SectionEmpty } - | { - result: SectionResult & { empty: SectionEmpty }; - view?: never; - empty?: SectionEmpty; - } -) & { - children: (data: Data, displayedInput: Input) => ReactNode; - /** Placeholder layout for an initial request; use the ready view's grid without copied values. */ - loading?: ReactNode; - error?: { title: ReactNode; description?: ReactNode; onRetry?: () => void }; - label?: string; - /** DataApp suppresses this local notice in favor of its page-level toast. */ - notice?: "inline" | "none"; - dimOnUpdate?: boolean; -} & Omit, "children">; - -/** One request boundary for any number of cards. Pass `useDataView` as `result` for - * automatic retry and stale-data notices, or a manually resolved `view`. */ -export function DataSection({ - view, - result, - children, - empty, - loading, - error, - label, - notice = result ? "inline" : "none", - dimOnUpdate = !!result, - ...props -}: DataSectionProps) { - const dataView = result?.view ?? view; - if (!dataView) throw new Error("DataSection needs a data view."); - const emptyState = empty ?? result?.empty; - if (!emptyState) throw new Error("DataSection needs an empty state."); - return ( - } - empty={} - error={(cause) => { - const presentation = errorPresentation(cause); - const retryAction = - error?.onRetry ?? - (presentation.retryable && result ? () => void result.refetch() : undefined); - return ( - Retry} - /> - ); - }} - staleError={() => - (error?.onRetry || result) && ( - - ) - } - > - {children} - - ); -} diff --git a/data-app/runtime/src/react/ui/DataTable.css b/data-app/runtime/src/react/ui/DataTable.css deleted file mode 100644 index efdd93cc..00000000 --- a/data-app/runtime/src/react/ui/DataTable.css +++ /dev/null @@ -1,69 +0,0 @@ -.altertable-data-table-scroll { - max-width: 100%; - overflow-x: auto; - overscroll-behavior-x: none; -} -.altertable-data-table-search { - margin: 8px 0 12px; -} -.altertable-data-table { - width: 100%; - border-collapse: collapse; - font-size: 12px; -} -.altertable-data-table caption { - padding: 0 0 8px; - color: var(--at-muted, #687078); - font-size: 12px; - text-align: start; -} -.altertable-data-table th, -.altertable-data-table td { - padding: 9px 10px; - border-bottom: 1px solid var(--at-border, #e3e6e8); - text-align: start; - white-space: nowrap; - cursor: default; -} -.altertable-data-table thead th { - color: var(--at-muted, #687078); - font-weight: 650; -} -.altertable-data-table tbody th { - color: var(--at-text, #202124); - font-weight: 550; -} -.altertable-data-table :is(th, td)[data-type="number"] { - text-align: end; - font-variant-numeric: tabular-nums; -} -.altertable-data-table :is(th, td)[data-type="datetime"] { - font-variant-numeric: tabular-nums; -} -.altertable-data-table-timestamp { - padding: 0; - border: 0; - background: transparent; - color: inherit; - font: inherit; - font-variant-numeric: tabular-nums; - cursor: help; -} -.altertable-data-table-timestamp:hover { - color: var(--at-accent, #4779dc); -} -.altertable-data-table-timestamp:focus-visible { - outline: 2px solid var(--at-accent, #4779dc); - outline-offset: 2px; - border-radius: 2px; -} -.altertable-data-table tbody tr:last-child :is(th, td) { - border-bottom: 0; -} -.altertable-data-table .altertable-data-table-empty-row td { - padding: 0; - white-space: normal; -} -.altertable-data-table-share { - font-variant-numeric: tabular-nums; -} diff --git a/data-app/runtime/src/react/ui/DataTable.tsx b/data-app/runtime/src/react/ui/DataTable.tsx deleted file mode 100644 index 02db127f..00000000 --- a/data-app/runtime/src/react/ui/DataTable.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import type { ComponentPropsWithRef, ReactNode } from "react"; -import { classNames } from "./classNames.ts"; -import { DateTimeTooltip } from "./DateTimeTooltip.tsx"; -import { EmptyState, type EmptyStateProps } from "./EmptyState.tsx"; -import { SearchField, type SearchFieldProps } from "./SearchField.tsx"; -import "./DataTable.css"; - -export type DataTableSearch = Pick< - SearchFieldProps, - "label" | "placeholder" | "value" | "onChange" | "resetValue" -> & { - /** Number of available records before filtering or a display limit. */ - itemCount: number; -}; -export type DataTableProps = ComponentPropsWithRef<"table"> & { searchable?: DataTableSearch }; - -/** Scrollable native table. Mark quantitative th/td cells with data-type="number"; - * the runtime aligns them to the end and uses tabular digits. */ -export function DataTable({ className, ref, searchable, ...props }: DataTableProps) { - return ( - <> - {searchable && - (searchable.itemCount > 5 || searchable.value !== (searchable.resetValue ?? "")) && ( - - )} -
- - - - ); -} - -/** Accepts a fraction of the stated whole, using `formatPercent` semantics. */ -export function DataTableShare({ value }: { value: number }) { - const ratio = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0; - const label = new Intl.NumberFormat("en-US", { - style: "percent", - minimumFractionDigits: 1, - maximumFractionDigits: 1, - }).format(ratio); - return {label}; -} - -export type DataTableEmptyRowProps = Pick & { - colSpan: number; -}; - -export function DataTableEmptyRow({ colSpan, title, description }: DataTableEmptyRowProps) { - return ( - - - - ); -} - -export type DataTableTimestampProps = { - value: string | Date; - children?: ReactNode; - timeZone?: string; -}; - -export function DataTableTimestamp({ value, children, timeZone }: DataTableTimestampProps) { - const date = value instanceof Date ? value : new Date(value); - if (Number.isNaN(date.getTime())) return <>{children ?? value.toString()}; - const timestamp = date.toISOString(); - return ( - - - - ); -} diff --git a/data-app/runtime/src/react/ui/DataViewToast.css b/data-app/runtime/src/react/ui/DataViewToast.css deleted file mode 100644 index a7942106..00000000 --- a/data-app/runtime/src/react/ui/DataViewToast.css +++ /dev/null @@ -1,54 +0,0 @@ -.altertable-data-view-toast-region { - position: fixed; - z-index: 100; - inset-inline: 16px; - bottom: max(20px, env(safe-area-inset-bottom)); - display: flex; - justify-content: center; - pointer-events: none; -} -.altertable-data-view-toast { - display: flex; - align-items: center; - gap: 10px; - width: fit-content; - max-width: min(100%, 560px); - max-height: 40dvh; - padding: 10px 12px; - overflow: auto; - border: 1px solid var(--at-border, #e3e6e8); - border-radius: var(--at-radius-surface, 12px); - background: var(--at-surface, #fff); - box-shadow: 0 10px 32px rgb(0 0 0 / 12%); - color: var(--at-text, #202124); - font-size: 12px; - line-height: 1.45; - pointer-events: auto; -} -.altertable-data-view-toast > svg { - flex: none; - color: var(--at-muted, #687078); -} -.altertable-data-view-toast[data-state="stale-error"] > svg { - color: var(--at-danger, #b42318); -} -.altertable-data-view-toast > span { - min-width: 0; -} -.altertable-data-view-toast .altertable-button { - flex: none; - min-height: 32px; -} -.altertable-data-view-toast-spinner { - animation: altertable-data-view-toast-spin 1s linear infinite; -} -@keyframes altertable-data-view-toast-spin { - to { - transform: rotate(360deg); - } -} -@media (prefers-reduced-motion: reduce) { - .altertable-data-view-toast-spinner { - animation: none; - } -} diff --git a/data-app/runtime/src/react/ui/DataViewToast.tsx b/data-app/runtime/src/react/ui/DataViewToast.tsx deleted file mode 100644 index 5e17530a..00000000 --- a/data-app/runtime/src/react/ui/DataViewToast.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useEffect, useState, type ReactNode } from "react"; -import type { DataView } from "../../core/data-view.ts"; -import { AppIcon } from "./icons.ts"; -import { Button } from "./Button.tsx"; -import "./DataViewToast.css"; - -export type DataViewToastProps = { - view: DataView; - message?: ReactNode; - notice?: ReactNode; - onRetry?: () => void; -}; - -/** One page-level refresh status. Updating waits briefly to avoid flashing on fast requests; - * a failed update stays visible with its retry action until the view changes. */ -export function DataViewToast({ - view, - message, - notice, - onRetry, -}: DataViewToastProps) { - const [delay, setDelay] = useState({ kind: view.kind, elapsed: false }); - if (delay.kind !== view.kind) setDelay({ kind: view.kind, elapsed: false }); - useEffect(() => { - if (view.kind !== "updating") return; - const timer = window.setTimeout(() => setDelay({ kind: "updating", elapsed: true }), 450); - return () => window.clearTimeout(timer); - }, [view.kind]); - const showUpdating = view.kind === "updating" && delay.kind === "updating" && delay.elapsed; - - if (view.kind !== "stale-error" && (view.kind !== "updating" || !showUpdating) && !notice) - return null; - const failed = view.kind === "stale-error"; - const showingUpdate = view.kind === "updating" && showUpdating; - const detail = view.kind === "stale-error" || view.kind === "updating" ? view.message : undefined; - const icon = failed ? "error" : showingUpdate ? "loading" : "live"; - const state = failed ? "stale-error" : showingUpdate ? "updating" : "notice"; - return ( -
-
- - {failed ? (message ?? detail) : showingUpdate ? (message ?? detail) : notice} - {failed && onRetry && } -
-
- ); -} diff --git a/data-app/runtime/src/react/ui/DateRangePicker.css b/data-app/runtime/src/react/ui/DateRangePicker.css deleted file mode 100644 index dbaec213..00000000 --- a/data-app/runtime/src/react/ui/DateRangePicker.css +++ /dev/null @@ -1,492 +0,0 @@ -.altertable-date-range { - position: relative; - display: inline-grid; - gap: 6px; - color: var(--at-text, #202124); - font-size: 14px; -} -.altertable-date-range .react-aria-Label { - color: var(--at-muted, #687078); - font-weight: 550; -} -.altertable-date-range .react-aria-Group { - display: flex; - align-items: center; - gap: 5px; - min-height: 36px; - padding: 0 2px 0 10px; - border: 1px solid var(--at-border, #e3e6e8); - border-radius: var(--at-radius-control, 7px); - background: var(--at-surface, #fff); - transition: border-color 120ms ease; -} -.altertable-date-range .react-aria-Group[data-hovered] { - border-color: color-mix(in srgb, var(--at-muted, #687078) 45%, var(--at-border, #e3e6e8)); -} -.altertable-variable-bar .altertable-date-range .react-aria-Group { - box-shadow: var(--at-shadow-surface, none); -} -.altertable-date-range .react-aria-Group[data-focus-within] { - border-color: var(--at-control-focus-border, var(--at-accent, #4779dc)); -} -.altertable-date-range[data-invalid] .react-aria-Group, -.altertable-date-range:has(.altertable-date-range-error) .react-aria-Group { - border-color: var(--at-danger, #b42318); -} -.altertable-date-range .react-aria-DateInput { - display: flex; - min-width: 0; - white-space: nowrap; - font-weight: 560; - letter-spacing: -0.01em; -} -.altertable-date-range .react-aria-DateSegment { - padding: 3px 1px; - border-radius: 3px; - font-variant-numeric: tabular-nums; - outline: none; -} -.altertable-date-range .react-aria-DateSegment[data-type="literal"] { - color: var(--at-muted, #687078); -} -.altertable-date-range .react-aria-DateSegment[data-focused] { - background: var(--at-accent, #4779dc); - color: var(--at-on-accent, #fff); -} -.altertable-date-range .react-aria-Group > span[aria-hidden] { - color: var(--at-muted, #687078); -} -.altertable-date-range-comparison { - flex: none; - padding: 2px 5px; - border-radius: 4px; - background: var(--at-subtle, #f6f7f7); - color: var(--at-muted, #687078); - font-size: 11px; - font-weight: 600; - white-space: nowrap; -} -.altertable-date-range .react-aria-Group .react-aria-Button { - display: grid; - place-items: center; - width: 32px; - height: 32px; - margin-left: auto; - border: 0; - border-radius: var(--at-radius-control, 7px); - background: transparent; - color: var(--at-muted, #687078); - cursor: pointer; - outline: none; - transition: - background-color 120ms ease, - color 120ms ease; -} -.altertable-date-range .react-aria-Group .react-aria-Button[data-hovered], -.altertable-date-range .react-aria-Group .react-aria-Button[data-pressed] { - background: var(--at-subtle, #f6f7f7); - color: var(--at-text, #202124); -} -.altertable-date-range .react-aria-Group .react-aria-Button[data-focus-visible] { - outline: 2px solid var(--at-accent, #4779dc); -} -.altertable-date-range .altertable-date-range-reset { - display: grid; - place-items: center; - flex: none; - width: 28px; - height: 28px; - padding: 0; - margin-left: auto; - border: 0; - border-radius: var(--at-radius-control, 7px); - background: transparent; - color: var(--at-muted, #687078); - cursor: pointer; -} -.altertable-date-range .altertable-date-range-reset:hover { - background: var(--at-subtle, #f6f7f7); - color: var(--at-text, #202124); -} -.altertable-date-range .altertable-date-range-reset:focus-visible { - outline: 2px solid var(--at-accent, #4779dc); -} -.altertable-date-range .altertable-date-range-reset + .react-aria-Button { - margin-left: 0; -} -.altertable-date-range-error { - position: absolute; - top: calc(100% + 6px); - right: 0; - z-index: 1; - width: max-content; - max-width: 100%; - color: var(--at-danger, #b42318); - font-size: 12px; -} - -.altertable-date-range-popover { - z-index: 100; - padding: 12px; - border: 1px solid var(--at-border, #e3e6e8); - border-radius: var(--at-radius-surface, 12px); - background: var(--at-surface, #fff); - color: var(--at-text, #202124); - box-shadow: var(--at-shadow-overlay, 0 18px 50px rgb(20 28 40 / 16%)); - font: 13px/1.4 var(--at-font, ui-sans-serif, system-ui, sans-serif); -} -.altertable-date-range-popover[data-entering] { - animation: altertable-calendar-in 140ms cubic-bezier(0.2, 0.7, 0.2, 1); -} -@keyframes altertable-calendar-in { - from { - opacity: 0; - translate: 0 -4px; - } -} -.altertable-date-range-popover .react-aria-Dialog { - outline: none; -} -.altertable-date-range-content { - display: grid; - grid-template-columns: minmax(150px, 190px) minmax(0, 1fr); - gap: 14px; -} -.altertable-date-range-presets { - display: flex; - flex-direction: column; - min-width: 0; - margin: 0; - padding: 0; - border: 0; - max-height: min(380px, 62dvh); - overflow-y: auto; - overscroll-behavior: contain; - padding-right: 12px; - border-right: 1px solid var(--at-border, #e3e6e8); -} -.altertable-date-range-preset-list { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} -.altertable-date-range-custom { - min-width: 0; -} -.altertable-date-range-heading { - display: block; - padding: 5px 8px 9px; - color: var(--at-muted, #687078); - font-size: 11px; - font-weight: 650; - letter-spacing: 0.04em; - text-transform: uppercase; -} -.altertable-date-range-popover .altertable-date-range-preset { - display: flex; - justify-content: flex-start; - width: 100%; - height: auto; - min-height: 34px; - padding: 7px 9px; - border: 0; - border-radius: var(--at-radius-control, 7px); - background: transparent; - color: var(--at-text, #202124); - text-align: left; - line-height: 1.25; - font: inherit; - cursor: pointer; -} -.altertable-date-range-popover .altertable-date-range-preset[data-hovered] { - background: var(--at-subtle, #f6f7f7); -} -.altertable-date-range-popover .altertable-date-range-preset[data-focus-visible] { - outline: 2px solid var(--at-accent, #4779dc); - outline-offset: -2px; -} -.altertable-date-range-popover .altertable-date-range-preset[aria-current="true"] { - background: var(--at-accent-subtle, #eaf0fb); - color: var(--at-accent, #4779dc); - font-weight: 650; -} -.altertable-date-range-footer { - display: flex; - align-items: end; - justify-content: space-between; - flex-wrap: wrap; - gap: 12px; - margin-top: 12px; - padding: 10px 12px; - border-radius: var(--at-radius-control, 7px); - background: var(--at-subtle, #f6f7f7); - color: var(--at-muted, #687078); - font-size: 11px; - font-variant-numeric: tabular-nums; -} -.altertable-date-range-footer > div { - display: grid; - gap: 2px; -} -.altertable-date-range-footer-label { - font-weight: 600; -} -.altertable-date-range-footer strong { - color: var(--at-text, #202124); - font-size: 13px; - font-weight: 650; -} -.altertable-date-range-footer-meta { - white-space: nowrap; -} -.altertable-date-range-compare { - display: flex; - align-items: flex-start; - gap: 9px; - margin-top: 10px; - padding: 10px 4px 2px; - border-top: 1px solid var(--at-border, #e3e6e8); - cursor: pointer; -} -.altertable-date-range-compare:has(input:disabled) { - opacity: 0.5; - cursor: default; -} -.altertable-date-range-compare input { - margin: 2px 0 0; - accent-color: var(--at-accent, #4779dc); -} -.altertable-date-range-compare small { - display: block; - margin-top: 2px; - color: var(--at-muted, #687078); - font-size: 11px; -} -.altertable-date-range-popover .react-aria-RangeCalendar { - width: max-content; - max-width: calc(100vw - 32px); -} -.altertable-date-range-popover .react-aria-RangeCalendar header { - display: flex; - align-items: center; - gap: 4px; - margin-bottom: 8px; -} -.altertable-date-range-popover .react-aria-Button:not(.altertable-date-range-preset) { - display: grid; - place-items: center; - width: 32px; - height: 32px; - padding: 0; - border: 0; - border-radius: var(--at-radius-control, 7px); - background: transparent; - color: inherit; - font: inherit; - cursor: pointer; - outline: none; -} -.altertable-date-range-popover .react-aria-Button:not(.altertable-date-range-preset)[data-hovered] { - background: var(--at-subtle, #f6f7f7); -} -.altertable-date-range-popover - .react-aria-Button:not(.altertable-date-range-preset)[data-focus-visible] { - outline: 2px solid var(--at-accent, #4779dc); -} -.altertable-date-range-popover - .react-aria-Button:not(.altertable-date-range-preset)[data-disabled] { - opacity: 0.35; - cursor: default; -} -.altertable-date-range-popover .altertable-calendar-select:first-of-type { - flex: 1; -} -.altertable-date-range-popover .altertable-calendar-select .react-aria-Button { - display: inline-flex; - align-items: center; - justify-content: space-between; - gap: 6px; - width: 100%; - min-width: 72px; - padding: 0 8px; - font-weight: 650; -} -.altertable-date-range-popover .altertable-calendar-select svg { - color: var(--at-muted, #687078); -} -.altertable-date-range-popover .react-aria-CalendarGrid { - border-collapse: separate; - border-spacing: 0 2px; -} -.altertable-date-range-popover .react-aria-CalendarGrid th, -.altertable-date-range-popover .react-aria-CalendarGrid td { - width: 36px; - padding: 0; - text-align: center; -} -.altertable-date-range-popover .react-aria-CalendarHeaderCell { - height: 28px; - color: var(--at-muted, #687078); - font-size: 11px; - font-weight: 550; -} -.altertable-date-range-popover .react-aria-CalendarCell { - display: flex; - align-items: center; - justify-content: center; - width: 36px; - height: 34px; - border-radius: var(--at-radius-control, 7px); - font-variant-numeric: tabular-nums; - line-height: 1; - outline: none; - cursor: pointer; -} -.altertable-date-range-popover .react-aria-CalendarCell[data-outside-month] { - visibility: hidden; -} -.altertable-date-range-popover .react-aria-CalendarCell[data-hovered]:not([data-selected]) { - background: var(--at-subtle, #f6f7f7); -} -.altertable-date-range-popover .react-aria-CalendarCell[data-selected] { - border-radius: 0; - background: var(--at-accent-subtle, #eaf0fb); -} -.altertable-date-range-popover .react-aria-CalendarCell[data-selection-start] { - border-start-start-radius: var(--at-radius-control, 7px); - border-end-start-radius: var(--at-radius-control, 7px); -} -.altertable-date-range-popover .react-aria-CalendarCell[data-selection-end] { - border-start-end-radius: var(--at-radius-control, 7px); - border-end-end-radius: var(--at-radius-control, 7px); -} -.altertable-date-range-popover .react-aria-CalendarCell[data-selection-start], -.altertable-date-range-popover .react-aria-CalendarCell[data-selection-end] { - background: var(--at-accent, #4779dc); - color: var(--at-on-accent, #fff); - font-weight: 650; -} -.altertable-date-range-popover td:first-child .react-aria-CalendarCell[data-selected] { - border-start-start-radius: var(--at-radius-control, 7px); - border-end-start-radius: var(--at-radius-control, 7px); -} -.altertable-date-range-popover td:last-child .react-aria-CalendarCell[data-selected] { - border-start-end-radius: var(--at-radius-control, 7px); - border-end-end-radius: var(--at-radius-control, 7px); -} -.altertable-date-range-popover .react-aria-CalendarCell[data-focus-visible] { - outline: 2px solid var(--at-accent, #4779dc); - outline-offset: -2px; -} -.altertable-date-range-popover .react-aria-CalendarCell[data-disabled], -.altertable-date-range-popover .react-aria-CalendarCell[data-unavailable] { - color: var(--at-muted, #687078); - opacity: 0.4; - cursor: default; -} -.altertable-date-range-popover .react-aria-CalendarCell[data-unavailable] { - text-decoration: line-through; -} - -.altertable-calendar-select-popover { - z-index: 150; - min-width: var(--trigger-width); - padding: 4px; - border: 1px solid var(--at-border, #e3e6e8); - border-radius: var(--at-radius-control, 7px); - background: var(--at-surface, #fff); - box-shadow: var(--at-shadow-overlay, 0 18px 50px rgb(20 28 40 / 16%)); - color: var(--at-text, #202124); - font: 13px/1.4 var(--at-font, ui-sans-serif, system-ui, sans-serif); -} -.altertable-calendar-select-popover .react-aria-ListBox { - max-height: min(260px, 45dvh); - overflow: auto; - outline: none; -} -.altertable-calendar-select-popover .react-aria-ListBoxItem { - padding: 7px 10px; - border-radius: var(--at-radius-control, 7px); - outline: none; - cursor: pointer; -} -.altertable-calendar-select-popover .react-aria-ListBoxItem[data-selected] { - font-weight: 650; -} -.altertable-calendar-select-popover .react-aria-ListBoxItem[data-focused], -.altertable-calendar-select-popover .react-aria-ListBoxItem[data-hovered] { - background: var(--at-subtle, #f6f7f7); -} -@media (max-width: 620px) { - .altertable-date-range-popover { - box-sizing: border-box; - width: min(420px, calc(100vw - 24px)); - max-height: calc(100dvh - 24px); - overflow-y: auto; - overscroll-behavior: contain; - } - .altertable-date-range-content { - grid-template-columns: minmax(0, 1fr); - gap: 10px; - } - .altertable-date-range-presets { - max-height: none; - overflow: visible; - padding: 0 0 10px; - border-right: 0; - border-bottom: 1px solid var(--at-border, #e3e6e8); - } - .altertable-date-range-preset-list { - flex-direction: row; - gap: 6px; - max-width: 100%; - overflow-x: auto; - overscroll-behavior-inline: contain; - scroll-snap-type: x proximity; - padding-bottom: 2px; - } - .altertable-date-range-popover .altertable-date-range-preset { - flex: none; - width: auto; - min-height: 36px; - border: 1px solid var(--at-border, #e3e6e8); - scroll-snap-align: start; - white-space: nowrap; - } - .altertable-date-range-heading { - padding-bottom: 4px; - } - .altertable-date-range-popover .react-aria-RangeCalendar, - .altertable-date-range-popover .react-aria-CalendarGrid { - width: 100%; - max-width: none; - } - .altertable-date-range-popover .react-aria-RangeCalendar header { - display: grid; - grid-template-columns: 32px minmax(0, 1fr) minmax(0, 1fr) 32px; - gap: 6px; - } - .altertable-date-range-popover .react-aria-CalendarGrid { - table-layout: fixed; - } - .altertable-date-range-popover .react-aria-CalendarGrid th, - .altertable-date-range-popover .react-aria-CalendarGrid td { - width: 14.2857%; - } - .altertable-date-range-popover .react-aria-CalendarCell { - width: 100%; - height: auto; - min-height: 44px; - aspect-ratio: 1; - } -} -@media (prefers-reduced-motion: reduce) { - .altertable-date-range .react-aria-Group, - .altertable-date-range .react-aria-Group .react-aria-Button { - transition: none; - } - .altertable-date-range-popover[data-entering] { - animation: none; - } -} diff --git a/data-app/runtime/src/react/ui/DateRangePicker.tsx b/data-app/runtime/src/react/ui/DateRangePicker.tsx deleted file mode 100644 index aef07cc3..00000000 --- a/data-app/runtime/src/react/ui/DateRangePicker.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import { useState, type ComponentProps, type ReactNode } from "react"; -import { parseDate, type CalendarDate } from "@internationalized/date"; -import { - Button, - CalendarCell, - CalendarGrid, - CalendarGridBody, - CalendarGridHeader, - CalendarHeaderCell, - CalendarMonthPicker, - CalendarYearPicker, - DateInput, - DateRangePicker as AriaDateRangePicker, - DateSegment, - Dialog, - Group, - ListBox, - ListBoxItem, - Popover, - RangeCalendar, - Select, - SelectValue, - type RangeValue, -} from "react-aria-components"; -import { classNames } from "./classNames.ts"; -import { AppIcon } from "./icons.ts"; -import { formatDateRange, pluralize } from "../../core/format.ts"; -import "./DateRangePicker.css"; - -import { - availableDatePresets, - inclusiveDays, - withinBounds, - type DatePreset, - type DatePresetId, - type DateRange, -} from "../../core/date-range.ts"; -export type { DatePresetId, DateRange } from "../../core/date-range.ts"; - -export type DateRangePickerProps = { - label?: string; - value: DateRange | null; - onChange: (range: DateRange | null) => void; - minDate?: string; - maxDate?: string; - maxRangeDays?: number; - timeZone?: string; - /** Used to display the reset affordance. The app variable owns the URL and reset meaning. */ - resetValue?: DateRange; - isDefault?: boolean; - onReset?: () => void; - onPresetChange?: (id: DatePresetId) => void; - selectedPresetId?: DatePresetId | null; - calendarFooter?: ReactNode; - comparison?: { - enabled: boolean; - range: DateRange | null; - onChange: (enabled: boolean) => void; - }; -} & Omit< - ComponentProps>, - | "children" - | "value" - | "onChange" - | "minValue" - | "maxValue" - | "isOpen" - | "onOpenChange" - | "defaultValue" - | "defaultOpen" ->; - -/** The app owns URL state; `dateRangeControl` binds a date variable to this picker. */ -export function DateRangePicker({ - label = "Date range", - value, - onChange, - minDate, - maxDate, - maxRangeDays, - timeZone, - resetValue, - isDefault, - onReset, - onPresetChange, - selectedPresetId, - calendarFooter, - comparison, - className, - ...props -}: DateRangePickerProps) { - const [error, setError] = useState(""); - const [open, setOpen] = useState(false); - const selected: RangeValue | null = value - ? { start: parseDate(value.start), end: parseDate(value.end) } - : null; - const presets = availableDatePresets({ minDate, maxDate, maxRangeDays, timeZone }); - const selectedDays = value ? inclusiveDays(value) : 0; - function choosePreset(preset: DatePreset) { - setError(""); - if (onPresetChange) onPresetChange(preset.id); - else onChange(preset.range); - setOpen(false); - } - return ( - { - setOpen(next); - if (next) setError(""); - }} - aria-label={`${props["aria-label"] ?? label}${comparison?.enabled ? ", compared with previous period" : ""}`} - className={(values) => - classNames( - "altertable-date-range", - typeof className === "function" ? className(values) : className, - ) - } - value={selected} - onChange={(range) => { - const next = range ? { start: range.start.toString(), end: range.end.toString() } : null; - if (next && !withinBounds(next, minDate, maxDate, maxRangeDays)) { - setError( - maxRangeDays - ? `Choose up to ${maxRangeDays} available days.` - : "Choose dates within the available range.", - ); - return; - } - setError(""); - onChange(next); - }} - minValue={minDate ? parseDate(minDate) : undefined} - maxValue={maxDate ? parseDate(maxDate) : undefined} - > - - {(segment) => } - - {(segment) => } - {comparison?.enabled && vs prior} - {(onReset || resetValue) && - value && - !( - isDefault ?? - (resetValue && value.start === resetValue.start && value.end === resetValue.end) - ) && ( - - )} - - - {error && ( - - {error} - - )} - - -
- {presets.length > 0 && ( -
- Quick ranges -
- {presets.map((preset) => ( - - ))} -
-
- )} -
- Custom range - - !!maxRangeDays && - !!anchorDate && - Math.abs(date.toDate("UTC").getTime() - anchorDate.toDate("UTC").getTime()) >= - maxRangeDays * 86_400_000 - } - > -
- - - {(picker) => ( - - )} - - - {(picker) => ( - - )} - - -
- - - {(day) => {day}} - - {(date) => } - -
-
-
- Selected dates - {value ? formatDateRange(value) : "Select a start and end date"} -
- {value && ( - - {selectedDays} {pluralize(selectedDays, "day")} - {timeZone ? ` · ${timeZone}` : ""} - - )} -
- {comparison && ( - - )} -
-
- {calendarFooter} -
-
-
- ); -} diff --git a/data-app/runtime/src/react/ui/DateTimeTooltip.css b/data-app/runtime/src/react/ui/DateTimeTooltip.css deleted file mode 100644 index cb6a0691..00000000 --- a/data-app/runtime/src/react/ui/DateTimeTooltip.css +++ /dev/null @@ -1,22 +0,0 @@ -.altertable-date-time-tooltip { - width: max-content; - max-width: min(480px, calc(100vw - 24px)); - padding: 10px 12px; -} -.altertable-date-time-rows { - display: grid; - gap: 6px; -} -.altertable-date-time-row { - display: grid; - grid-template-columns: minmax(80px, 1fr) auto; - gap: 16px; - font-variant-numeric: tabular-nums; -} -.altertable-date-time-row time { - text-align: right; - overflow-wrap: anywhere; -} -.altertable-date-time-label { - color: var(--at-muted, #626b73); -} diff --git a/data-app/runtime/src/react/ui/DateTimeTooltip.tsx b/data-app/runtime/src/react/ui/DateTimeTooltip.tsx deleted file mode 100644 index 4e1dff80..00000000 --- a/data-app/runtime/src/react/ui/DateTimeTooltip.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useEffect, useState, type ReactNode } from "react"; -import { HelpPopover, type HelpPopoverProps } from "./HelpPopover.tsx"; -import "./DateTimeTooltip.css"; - -export type DateTimeTooltipProps = { - date: Date; - - timeZone?: string; - children?: ReactNode; -} & Omit; - -function formattedDate(date: Date, timeZone: string): string { - return new Intl.DateTimeFormat(undefined, { - timeZone, - dateStyle: "medium", - timeStyle: "medium", - }).format(date); -} - -function relativeDate(date: Date, now: number): string { - const seconds = Math.round((date.getTime() - now) / 1000); - const absolute = Math.abs(seconds); - const [value, unit]: [number, Intl.RelativeTimeFormatUnit] = - absolute < 60 - ? [seconds, "second"] - : absolute < 3600 - ? [Math.round(seconds / 60), "minute"] - : absolute < 86_400 - ? [Math.round(seconds / 3600), "hour"] - : [Math.round(seconds / 86_400), "day"]; - return new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }).format(value, unit); -} - -/** Pass the viewer's account time zone when known; the popover also exposes UTC and Unix time. */ -export function DateTimeTooltip({ - date, - timeZone, - children, - triggerClassName, - panelClassName, - ...props -}: DateTimeTooltipProps) { - const [now, setNow] = useState(Date.now); - useEffect(() => { - const interval = window.setInterval(() => setNow(Date.now()), 30_000); - return () => window.clearInterval(interval); - }, []); - - const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; - const primaryTimeZone = timeZone ?? browserTimeZone; - const rows = [ - { - label: timeZone ? `Account · ${timeZone}` : `Browser · ${browserTimeZone}`, - zone: primaryTimeZone, - }, - ...(primaryTimeZone === "UTC" ? [] : [{ label: "UTC", zone: "UTC" }]), - ...(timeZone && timeZone !== browserTimeZone - ? [{ label: `Browser · ${browserTimeZone}`, zone: browserTimeZone }] - : []), - ]; - const timestamp = date.toISOString(); - return ( - {formattedDate(date, timeZone ?? browserTimeZone)} - ) - } - > -
- {rows.map(({ label, zone }) => ( - - {label} - - - ))} - - Relative - - - - Timestamp - - -
-
- ); -} diff --git a/data-app/runtime/src/react/ui/EmptyState.css b/data-app/runtime/src/react/ui/EmptyState.css deleted file mode 100644 index 0e000c11..00000000 --- a/data-app/runtime/src/react/ui/EmptyState.css +++ /dev/null @@ -1,27 +0,0 @@ -.altertable-empty-state { - display: flex; - min-height: 176px; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 5px; - padding: 24px; - color: var(--at-muted, #687078); - text-align: center; -} -.altertable-empty-state strong { - color: var(--at-muted, #687078); - font-size: 12px; - font-weight: 450; -} -.altertable-empty-state p { - max-width: 40ch; - margin: 0; - font-size: 12px; - line-height: 1.5; - white-space: normal; -} -.altertable-empty-state[data-variant="table"] { - min-height: 112px; - padding: 18px; -} diff --git a/data-app/runtime/src/react/ui/EmptyState.tsx b/data-app/runtime/src/react/ui/EmptyState.tsx deleted file mode 100644 index eb9ac3cd..00000000 --- a/data-app/runtime/src/react/ui/EmptyState.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { ReactNode } from "react"; -import { classNames } from "./classNames.ts"; -import "./EmptyState.css"; - -export type EmptyStateProps = { - title: ReactNode; - description?: ReactNode; - variant?: "visual" | "table"; - className?: string; -}; - -export function EmptyState({ title, description, variant = "visual", className }: EmptyStateProps) { - return ( -
- {title} - {description &&

{description}

} -
- ); -} diff --git a/data-app/runtime/src/react/ui/GettingStarted.css b/data-app/runtime/src/react/ui/GettingStarted.css deleted file mode 100644 index bc57b05e..00000000 --- a/data-app/runtime/src/react/ui/GettingStarted.css +++ /dev/null @@ -1,149 +0,0 @@ -.starter-eyebrow { - color: var(--at-muted, #687078); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} -.starter-connection { - display: flex; - align-items: flex-start; - gap: 20px; - min-width: 0; - padding: 30px; - border: 1px solid var(--at-border, #e3e6e8); - border-radius: var(--at-radius-surface, 12px); - background: var(--at-surface, #fff); -} -.starter-connection-icon { - display: grid; - flex: none; - place-items: center; - width: 48px; - height: 48px; - border-radius: 14px; - background: var(--at-subtle, #f6f7f7); - color: var(--at-muted, #687078); -} -.starter-connection[data-state="connected"] .starter-connection-icon { - background: color-mix(in srgb, var(--at-accent, #405d47) 12%, var(--at-surface, #fff)); - color: var(--at-accent, #405d47); -} -.starter-connection[data-state="error"] .starter-connection-icon { - background: color-mix(in srgb, var(--at-danger, #b42318) 9%, var(--at-surface, #fff)); - color: var(--at-danger, #b42318); -} -.starter-connection[data-state="checking"] .starter-connection-icon svg { - animation: starter-spin 1.5s linear infinite; -} -.starter-connection-copy { - flex: 1; - min-width: 0; -} -.starter-connection h2 { - margin: 8px 0 6px; - font-family: var(--at-font-heading, inherit); - font-size: clamp(26px, 3.2vw, 36px); - font-weight: 680; - letter-spacing: -0.035em; - line-height: 1.15; -} -.starter-connection p { - max-width: 62ch; - margin: 0; - color: var(--at-muted, #687078); - font-size: 14px; - line-height: 1.55; -} -.starter-connection small { - display: block; - margin-top: 14px; - color: var(--at-muted, #687078); - font-size: 12px; -} -.starter-connection > .altertable-button { - flex: none; - align-self: center; -} -.starter-next { - min-width: 0; - padding: 8px 0; -} -.starter-next-heading h2 { - margin: 8px 0 4px; - font-family: var(--at-font-heading, inherit); - font-size: clamp(20px, 2.4vw, 26px); - font-weight: 680; - letter-spacing: -0.025em; -} -.starter-next-heading p { - margin: 0; - color: var(--at-muted, #687078); - font-size: 14px; - line-height: 1.5; -} -.starter-steps { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; - margin: 22px 0 0; - padding: 0; - list-style: none; - counter-reset: starter-step; -} -.starter-steps li { - min-width: 0; - padding: 20px; - border: 1px solid var(--at-border, #e3e6e8); - border-radius: var(--at-radius-surface, 12px); - background: var(--at-surface, #fff); - counter-increment: starter-step; -} -.starter-steps li::before { - display: block; - margin-bottom: 22px; - color: var(--at-accent, #405d47); - font-size: 12px; - font-weight: 700; - content: "0" counter(starter-step); -} -.starter-steps strong { - font-size: 14px; - font-weight: 650; -} -.starter-steps p { - margin: 8px 0 0; - color: var(--at-muted, #687078); - font-size: 13px; - line-height: 1.5; -} -.starter-steps code { - color: var(--at-text, #202124); - font-size: 0.92em; - overflow-wrap: anywhere; -} -@keyframes starter-spin { - to { - transform: rotate(360deg); - } -} -@media (prefers-reduced-motion: reduce) { - .starter-connection[data-state="checking"] .starter-connection-icon svg { - animation: none; - } -} -@media (max-width: 700px) { - .starter-connection { - flex-wrap: wrap; - padding: 22px; - } - .starter-connection > .altertable-button { - margin-left: 68px; - } - .starter-steps { - grid-template-columns: 1fr; - } - .starter-steps li::before { - margin-bottom: 10px; - } -} diff --git a/data-app/runtime/src/react/ui/GettingStarted.tsx b/data-app/runtime/src/react/ui/GettingStarted.tsx deleted file mode 100644 index 6280472d..00000000 --- a/data-app/runtime/src/react/ui/GettingStarted.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { createDataClient } from "../../client/index.ts"; -import type { DataAppConfig } from "../../core/config.ts"; -import { connectionCheck } from "../../core/contract.ts"; -import { createDataHooks } from "../hooks.ts"; -import { AppIcon } from "./icons.ts"; -import { Button } from "./Button.tsx"; -import { DataApp } from "./DataApp.tsx"; -import type { DataContext } from "./data-context.ts"; -import "./GettingStarted.css"; - -const { useDataQuery } = createDataHooks<{ connection: ReturnType }>( - createDataClient(), -); - -/** Query-backed connection state and next steps for a newly created app. Mount within - * `DataAppProvider` and register `connection: connectionCheck()` on the server. */ -export function GettingStarted({ - config, - dataContext, -}: { - config: DataAppConfig; - dataContext: DataContext; -}) { - const connection = useDataQuery("connection", {}); - const state = connection.error - ? "error" - : connection.isFetching || !connection.data - ? "checking" - : "connected"; - - return ( - void connection.refetch(), - tooltip: "Check connection", - buttonProps: { "aria-label": "Check connection" }, - }} - > -
-
- -
-
- Lakehouse connection -

- {state === "connected" - ? "Connected" - : state === "error" - ? "Connection not verified" - : "Checking connection…"} -

-

- {state === "connected" - ? "A query completed successfully using this app’s current profile. You’re ready to build your first view." - : state === "error" - ? connection.data - ? "An earlier query succeeded, but the latest check failed. Check the profile and try again." - : "The query could not complete. Check the profile and lakehouse access, then try again." - : "Running a lightweight query with this app’s current profile."} -

- {state === "connected" && ( - This check does not verify access to every dataset. - )} -
- {state === "error" && } -
- -
-
- Next steps -

Turn this starter into a useful app

-

Start with one question your readers need answered.

-
-
    -
  1. - Choose a question -

    Decide what someone should learn or do after opening this app.

    -
  2. -
  3. - Find the right data -

    Inspect the relevant catalogs, tables, and columns before writing the query.

    -
  4. -
  5. - Build the first view -

    - Replace the connection check in src/operations.ts, then render its result - in src/App.tsx. -

    -
  6. -
-
-
- ); -} diff --git a/data-app/runtime/src/react/ui/GlossaryDefinition.css b/data-app/runtime/src/react/ui/GlossaryDefinition.css deleted file mode 100644 index 66c03888..00000000 --- a/data-app/runtime/src/react/ui/GlossaryDefinition.css +++ /dev/null @@ -1,32 +0,0 @@ -.altertable-glossary-definition-trigger { - display: inline; - padding: 0; - border: 0; - background: none; - color: inherit; - font: inherit; - font-weight: inherit; - text-align: inherit; - text-decoration: underline dotted var(--at-muted, #687078); - text-decoration-thickness: 1px; - text-underline-offset: 0.2em; - cursor: help; -} -.altertable-glossary-definition-trigger:hover, -.altertable-glossary-definition-trigger[data-open="true"] { - text-decoration-color: var(--at-text, #202124); -} -.altertable-glossary-definition-trigger:focus-visible { - border-radius: 2px; - outline: 2px solid var(--at-accent, #405d47); - outline-offset: 2px; -} -.altertable-glossary-definition-popover { - width: min(320px, calc(100vw - 24px)); -} -.altertable-glossary-definition-popover > strong { - display: block; - margin-bottom: 4px; - font-size: 13px; - font-weight: 650; -} diff --git a/data-app/runtime/src/react/ui/GlossaryDefinition.tsx b/data-app/runtime/src/react/ui/GlossaryDefinition.tsx deleted file mode 100644 index b178eafe..00000000 --- a/data-app/runtime/src/react/ui/GlossaryDefinition.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { ReactNode } from "react"; -import type { GlossaryEntry } from "./data-context.ts"; -import { HelpPopover } from "./HelpPopover.tsx"; -import "./GlossaryDefinition.css"; - -export type GlossaryDefinitionProps = { - entry: GlossaryEntry; - children?: ReactNode; -}; - -export function GlossaryDefinition({ entry, children }: GlossaryDefinitionProps) { - return ( - - {entry.term} -
{entry.definition}
-
- ); -} diff --git a/data-app/runtime/src/react/ui/GlossaryExplanation.tsx b/data-app/runtime/src/react/ui/GlossaryExplanation.tsx deleted file mode 100644 index 34f04328..00000000 --- a/data-app/runtime/src/react/ui/GlossaryExplanation.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { AppIcon } from "./icons.ts"; -import type { ComponentPropsWithRef, ReactNode } from "react"; -import type { DisclosedQuery } from "../../core/contract.ts"; -import type { DataContext, GlossaryEntry } from "./data-context.ts"; -import { AboutData, type AboutEmpty } from "./AboutData.tsx"; -import "./Inspect.css"; - -export type GlossaryExplanationProps = { - entry: GlossaryEntry; - empty?: AboutEmpty; - title?: ReactNode; - description?: ReactNode; - visual?: ReactNode; - queries?: DisclosedQuery[]; - dataContext?: DataContext; -} & Omit, "children" | "title">; - -/** Opens the inspect sheet for one glossary term. Prefer a card's evidence slot - * when the surface already has a title, description, and visual. */ -export function GlossaryExplanation({ - entry, - empty, - title, - description, - visual, - queries, - dataContext, - className, - ...props -}: GlossaryExplanationProps) { - return ( - - - - ); -} diff --git a/data-app/runtime/src/react/ui/Grid.css b/data-app/runtime/src/react/ui/Grid.css deleted file mode 100644 index d585b816..00000000 --- a/data-app/runtime/src/react/ui/Grid.css +++ /dev/null @@ -1,116 +0,0 @@ -.altertable-grid { - display: grid; - container: altertable-grid / inline-size; - min-width: 0; - align-items: start; - align-content: start; - --at-grid-gap: var(--at-layout-gap, 24px); - --at-grid-min-item-width: 240px; - gap: var(--at-grid-gap); - /* The calculated track caps the column count; the minimum lets narrow containers wrap. */ - grid-template-columns: repeat( - auto-fit, - minmax(min(100%, max(var(--at-grid-min-item-width), var(--at-grid-column-width, 100%))), 1fr) - ); -} -.altertable-grid > * { - min-width: 0; - min-height: 0; -} -.altertable-grid[data-align="stretch"] { - align-items: stretch; -} -.altertable-grid[data-gap="sm"] { - --at-grid-gap: var(--at-space-sm, 8px); -} -.altertable-grid[data-gap="md"] { - --at-grid-gap: var(--at-space-md, 16px); -} -.altertable-grid[data-gap="lg"] { - --at-grid-gap: var(--at-layout-gap, 24px); -} -.altertable-grid[data-min-item-width="compact"] { - --at-grid-min-item-width: 192px; -} -.altertable-grid[data-min-item-width="wide"] { - --at-grid-min-item-width: 320px; -} -.altertable-grid[data-columns="2"] { - --at-grid-column-width: calc((100% - var(--at-grid-gap)) / 2); -} -.altertable-grid[data-columns="3"] { - --at-grid-column-width: calc((100% - var(--at-grid-gap) - var(--at-grid-gap)) / 3); -} -.altertable-grid[data-columns="4"] { - --at-grid-column-width: calc( - (100% - var(--at-grid-gap) - var(--at-grid-gap) - var(--at-grid-gap)) / 4 - ); -} - -.altertable-grid-item { - min-width: 0; - min-height: 0; -} - -@container altertable-grid (min-width: 408px) { - .altertable-grid:not([data-columns="1"])[data-min-item-width="compact"] - > .altertable-grid-item[data-span="2"] { - grid-column: span 2; - } -} -@container altertable-grid (min-width: 504px) { - .altertable-grid:not([data-columns="1"])[data-min-item-width="regular"] - > .altertable-grid-item[data-span="2"] { - grid-column: span 2; - } -} -@container altertable-grid (min-width: 664px) { - .altertable-grid:not([data-columns="1"])[data-min-item-width="wide"] - > .altertable-grid-item[data-span="2"] { - grid-column: span 2; - } -} - -/* Two-track layouts stack spans and let an unpaired card fill the row. */ -@container altertable-grid (max-width: 639px) { - .altertable-grid[data-columns="3"][data-min-item-width="compact"]:has( - > .altertable-grid-item[data-span="2"] - ) - > * { - grid-column: 1 / -1; - } - .altertable-grid[data-columns="3"][data-min-item-width="compact"]:not( - :has(> .altertable-grid-item[data-span="2"]) - ) - > :last-child:nth-child(odd) { - grid-column: 1 / -1; - } -} -@container altertable-grid (max-width: 783px) { - .altertable-grid[data-columns="3"][data-min-item-width="regular"]:has( - > .altertable-grid-item[data-span="2"] - ) - > * { - grid-column: 1 / -1; - } - .altertable-grid[data-columns="3"][data-min-item-width="regular"]:not( - :has(> .altertable-grid-item[data-span="2"]) - ) - > :last-child:nth-child(odd) { - grid-column: 1 / -1; - } -} -@container altertable-grid (max-width: 1023px) { - .altertable-grid[data-columns="3"][data-min-item-width="wide"]:has( - > .altertable-grid-item[data-span="2"] - ) - > * { - grid-column: 1 / -1; - } - .altertable-grid[data-columns="3"][data-min-item-width="wide"]:not( - :has(> .altertable-grid-item[data-span="2"]) - ) - > :last-child:nth-child(odd) { - grid-column: 1 / -1; - } -} diff --git a/data-app/runtime/src/react/ui/Grid.tsx b/data-app/runtime/src/react/ui/Grid.tsx deleted file mode 100644 index a536349d..00000000 --- a/data-app/runtime/src/react/ui/Grid.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { ComponentPropsWithRef, ReactNode } from "react"; -import { classNames } from "./classNames.ts"; -import "./Grid.css"; - -export type GridProps = { - children: ReactNode; - /** Maximum columns; cards wrap sooner when the container cannot fit their minimum width. */ - columns?: 1 | 2 | 3 | 4; - minItemWidth?: "compact" | "regular" | "wide"; - gap?: "sm" | "md" | "lg"; - /** Stretch peer cards to the tallest item in their row only when needed. */ - align?: "stretch" | "start"; -} & Omit, "children">; - -/** Responsive layout for peer cards and panels. Cards keep their content height by default. */ -export function Grid({ - children, - columns = 1, - minItemWidth = "regular", - gap = "lg", - align = "start", - className, - ...props -}: GridProps) { - return ( -
- {children} -
- ); -} diff --git a/data-app/runtime/src/react/ui/GridItem.tsx b/data-app/runtime/src/react/ui/GridItem.tsx deleted file mode 100644 index d8b69ec9..00000000 --- a/data-app/runtime/src/react/ui/GridItem.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { ComponentPropsWithRef, ReactNode } from "react"; -import { classNames } from "./classNames.ts"; - -export type GridItemProps = { - children: ReactNode; - /** Maximum tracks occupied; collapses to one when the grid container is narrow. */ - span?: 1 | 2; -} & Omit, "children">; - -export function GridItem({ children, span = 1, className, ...props }: GridItemProps) { - return ( -
- {children} -
- ); -} diff --git a/data-app/runtime/src/react/ui/HelpPopover.css b/data-app/runtime/src/react/ui/HelpPopover.css deleted file mode 100644 index 6d824c6c..00000000 --- a/data-app/runtime/src/react/ui/HelpPopover.css +++ /dev/null @@ -1,26 +0,0 @@ -.altertable-help-popover { - z-index: 1000; - max-width: calc(100vw - 24px); - max-height: min(65dvh, 560px); - overflow: auto; - overscroll-behavior: contain; - padding: var(--at-space-md, 16px); - border: 1px solid var(--at-border, #e3e6e8); - border-radius: var(--at-radius-overlay, 12px); - background: var(--at-surface, #fff); - box-shadow: var(--at-shadow-overlay, 0 18px 50px rgb(20 28 40 / 16%)); - color: var(--at-text, #202124); - font: 13px/1.5 var(--at-font, ui-sans-serif, system-ui, sans-serif); - animation: altertable-popover-in 140ms cubic-bezier(0.2, 0.7, 0.2, 1); -} -@keyframes altertable-popover-in { - from { - opacity: 0; - translate: 0 -2px; - } -} -@media (prefers-reduced-motion: reduce) { - .altertable-help-popover { - animation: none; - } -} diff --git a/data-app/runtime/src/react/ui/HelpPopover.tsx b/data-app/runtime/src/react/ui/HelpPopover.tsx deleted file mode 100644 index a2792835..00000000 --- a/data-app/runtime/src/react/ui/HelpPopover.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { useState, type ComponentPropsWithRef, type ReactNode, type RefObject } from "react"; -import { - autoUpdate, - flip, - FloatingFocusManager, - FloatingPortal, - offset, - safePolygon, - shift, - useClick, - useDismiss, - useFloating, - useHover, - useInteractions, - useMergeRefs, - useRole, - type Placement, -} from "@floating-ui/react"; -import { classNames } from "./classNames.ts"; -import "./HelpPopover.css"; - -export type HelpPopoverTriggerProps = Omit, "children" | "type">; -export type HelpPopoverPanelProps = Omit, "children" | "role">; - -export type HelpPopoverProps = { - trigger: ReactNode; - triggerLabel: string; - label: string; - children: ReactNode; - triggerClassName?: string; - panelClassName?: string; - placement?: Placement; - portalRoot?: RefObject; - triggerProps?: HelpPopoverTriggerProps; - panelProps?: HelpPopoverPanelProps; - open?: boolean; - onOpenChange?: (open: boolean) => void; -}; - -/** A supplemental explanation that opens on click, Enter, or a deliberate mouse hover. Tabbing - * past the trigger does not open it, and a click keeps a hover-opened panel in place. - * triggerProps and panelProps target its two surfaces. */ -export function HelpPopover({ - trigger, - triggerLabel, - label, - children, - triggerClassName, - panelClassName, - portalRoot, - triggerProps, - panelProps, - placement = "bottom-start", - open: openProp, - onOpenChange, -}: HelpPopoverProps) { - const [uncontrolledOpen, setUncontrolledOpen] = useState(false); - const [dialogRoot, setDialogRoot] = useState(null); - const open = openProp ?? uncontrolledOpen; - function setOpen(next: boolean) { - onOpenChange?.(next); - if (openProp === undefined) setUncontrolledOpen(next); - } - const { refs, floatingStyles, context } = useFloating({ - open, - onOpenChange: setOpen, - placement, - strategy: "fixed", - whileElementsMounted: autoUpdate, - middleware: [offset(8), flip(), shift({ padding: 12 })], - }); - const { getReferenceProps, getFloatingProps } = useInteractions([ - useHover(context, { - mouseOnly: true, - delay: { open: 400, close: 120 }, - handleClose: safePolygon(), - }), - useClick(context), - useDismiss(context), - useRole(context, { role: "dialog" }), - ]); - const mergedTriggerRef = useMergeRefs([ - refs.setReference, - triggerProps?.ref, - (element: HTMLButtonElement | null) => setDialogRoot(element?.closest("dialog") ?? null), - ]); - const mergedPanelRef = useMergeRefs([refs.setFloating, panelProps?.ref]); - return ( - <> - - {open && ( - - -
- {children} -
-
-
- )} - - ); -} diff --git a/data-app/runtime/src/react/ui/IconButton.tsx b/data-app/runtime/src/react/ui/IconButton.tsx deleted file mode 100644 index 7d9755ea..00000000 --- a/data-app/runtime/src/react/ui/IconButton.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { ComponentPropsWithRef, ReactNode, RefObject } from "react"; -import { Button, type ButtonProps } from "./Button.tsx"; -import { AppIcon, type AppIconName } from "./icons.ts"; -import { Kbd } from "./Kbd.tsx"; -import { ariaKeyShortcuts, type Shortcut } from "./shortcuts.ts"; -import { Tooltip } from "./Tooltip.tsx"; - -export type IconButtonProps = Omit, "children" | "aria-label"> & { - icon: AppIconName; - label: string; - variant?: ButtonProps["variant"]; - shortcut?: Shortcut | { label: string; aria: string }; - tooltip?: ReactNode; - portalRoot?: RefObject; - tooltipPlacement?: "top" | "bottom"; - tooltipAlign?: "start" | "center" | "end"; - children?: ReactNode; -}; - -export function IconButton({ - icon, - label, - variant = "outline", - shortcut, - tooltip, - portalRoot, - tooltipPlacement, - tooltipAlign, - children, - type = "button", - ...props -}: IconButtonProps) { - const keyHint = - shortcut && - ("modifier" in shortcut ? : {shortcut.label}); - const keyAria = shortcut && ("modifier" in shortcut ? ariaKeyShortcuts(shortcut) : shortcut.aria); - return ( - - {label} - {keyHint && <> {keyHint}} - - ) - } - portalRoot={portalRoot} - placement={tooltipPlacement} - align={tooltipAlign} - > - - - ); -} diff --git a/data-app/runtime/src/react/ui/Inspect.css b/data-app/runtime/src/react/ui/Inspect.css deleted file mode 100644 index 169f8423..00000000 --- a/data-app/runtime/src/react/ui/Inspect.css +++ /dev/null @@ -1,48 +0,0 @@ -.altertable-inspect-trigger { - display: inline-grid; - place-items: center; - flex: none; - border: 0; - border-radius: var(--at-radius-control, 6px); - background: transparent; - color: var(--at-muted, #687078); - cursor: pointer; - transition: - background-color 120ms ease, - color 120ms ease; -} -.altertable-inspect-trigger:hover, -.altertable-inspect-trigger[data-open="true"] { - background: var(--at-subtle, #f6f7f7); - color: var(--at-text, #202124); -} -.altertable-inspect-trigger:focus-visible { - outline: 2px solid var(--at-accent, #4779dc); - outline-offset: 2px; -} -@media (hover: hover) and (pointer: fine) { - :is( - .altertable-metric-widget .altertable-metric-help, - .altertable-data-panel .altertable-data-panel-help - ) { - opacity: 0; - transition: opacity 120ms ease; - } - :is( - .altertable-metric-widget:hover .altertable-metric-help, - .altertable-metric-widget:focus-within .altertable-metric-help, - .altertable-data-panel:hover .altertable-data-panel-help, - .altertable-data-panel:focus-within .altertable-data-panel-help, - .altertable-metric-help:has([data-open="true"]), - .altertable-data-panel-help:has([data-open="true"]) - ) { - opacity: 1; - } -} -@media (prefers-reduced-motion: reduce) { - .altertable-inspect-trigger, - .altertable-metric-help, - .altertable-data-panel-help { - transition: none; - } -} diff --git a/data-app/runtime/src/react/ui/InspectionContext.tsx b/data-app/runtime/src/react/ui/InspectionContext.tsx deleted file mode 100644 index fb70a39c..00000000 --- a/data-app/runtime/src/react/ui/InspectionContext.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { createContext, useContext } from "react"; -import type { DisclosedQuery } from "../../core/contract.ts"; -import type { AboutEmpty } from "./AboutData.tsx"; -import type { DataContext } from "./data-context.ts"; - -export type InspectionDefaults = { - dataContext: DataContext; - empty?: AboutEmpty; - queries?: DisclosedQuery[]; -}; - -export const InspectionContext = createContext(null); - -export function useInspectionDefaults(): InspectionDefaults | null { - return useContext(InspectionContext); -} diff --git a/data-app/runtime/src/react/ui/Kbd.css b/data-app/runtime/src/react/ui/Kbd.css deleted file mode 100644 index e69f4497..00000000 --- a/data-app/runtime/src/react/ui/Kbd.css +++ /dev/null @@ -1,14 +0,0 @@ -.altertable-kbd { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 1.6em; - padding: 2px 5px; - border: 1px solid var(--at-border, #e3e6e8); - border-radius: 5px; - background: var(--at-subtle, #f6f7f7); - color: var(--at-muted, #687078); - font: 600 11px/1.2 var(--at-font, ui-sans-serif, system-ui, sans-serif); - font-variant-numeric: tabular-nums; - white-space: nowrap; -} diff --git a/data-app/runtime/src/react/ui/Kbd.tsx b/data-app/runtime/src/react/ui/Kbd.tsx deleted file mode 100644 index d37b5cd9..00000000 --- a/data-app/runtime/src/react/ui/Kbd.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { ComponentPropsWithRef } from "react"; -import { classNames } from "./classNames.ts"; -import { shortcutLabel, type Shortcut } from "./shortcuts.ts"; -import "./Kbd.css"; - -export type KbdProps = ComponentPropsWithRef<"kbd"> & { shortcut?: Shortcut }; - -/** A visible key or shortcut. Keep aria-keyshortcuts on the associated control. */ -export function Kbd({ shortcut, children, className, ...props }: KbdProps) { - return ( - - {shortcut ? shortcutLabel(shortcut) : children} - - ); -} diff --git a/data-app/runtime/src/react/ui/LiveControl.css b/data-app/runtime/src/react/ui/LiveControl.css deleted file mode 100644 index 1573a627..00000000 --- a/data-app/runtime/src/react/ui/LiveControl.css +++ /dev/null @@ -1,102 +0,0 @@ -.altertable-live-control { - display: inline-flex; - align-items: stretch; - height: 36px; - overflow: hidden; - border: 1px solid var(--at-border, #e3e6e8); - border-radius: var(--at-radius-control, 7px); - background: var(--at-surface, #fff); - box-shadow: var(--at-shadow-surface, 0 3px 16px rgb(20 28 40 / 5%)); -} -.altertable-live-control .altertable-button[data-size="icon"] { - position: relative; - width: 35px; - min-width: 35px; - min-height: 34px; - height: 34px; - padding: 0; - border: 0; - border-radius: 0; - background: transparent; - box-shadow: none; -} -.altertable-live-control[data-active="true"] { - border-color: var(--at-accent, #4779dc); -} -.altertable-live-control[data-active="true"] .altertable-button { - color: var(--at-accent, #4779dc); -} -.altertable-live-control[data-active="true"] .altertable-button::after { - position: absolute; - top: 5px; - right: 5px; - width: 5px; - height: 5px; - border-radius: 50%; - background: currentColor; - content: ""; -} -.altertable-live-frequency { - width: 28px; - height: 34px; - border-left: 1px solid var(--at-border, #e3e6e8); -} -.altertable-live-frequency > button { - display: grid; - place-items: center; - width: 100%; - height: 100%; - padding: 0; - border: 0; - border-radius: 0; - background: transparent; - color: var(--at-muted, #687078); - cursor: pointer; -} -.altertable-live-frequency > button:hover { - background: var(--at-subtle, #f6f7f7); -} -.altertable-live-frequency > button[data-focus-visible] { - outline: 2px solid var(--at-accent, #4779dc); - outline-offset: 2px; -} -.altertable-live-frequency-popover { - z-index: 1000; - min-width: 190px; - padding: 4px; - border: 1px solid var(--at-border, #e3e6e8); - border-radius: var(--at-radius-control, 7px); - background: var(--at-surface, #fff); - color: var(--at-text, #202326); - box-shadow: 0 12px 32px rgb(0 0 0 / 0.18); -} -.altertable-live-frequency-popover [role="option"] { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - min-height: 34px; - padding: 6px 9px; - border-radius: 4px; - font-size: 13px; - cursor: pointer; - outline: none; -} -.altertable-live-frequency-popover [role="option"]:is([data-focused], [data-hovered]) { - background: var(--at-subtle, #f6f7f7); -} -.altertable-live-frequency-popover [role="option"][data-selected] { - color: var(--at-accent, #4779dc); - font-weight: 600; -} -.altertable-live-frequency-popover [role="option"]:not([data-selected]) svg { - visibility: hidden; -} -.altertable-visually-hidden { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; -} diff --git a/data-app/runtime/src/react/ui/LiveControl.tsx b/data-app/runtime/src/react/ui/LiveControl.tsx deleted file mode 100644 index 1c98d73c..00000000 --- a/data-app/runtime/src/react/ui/LiveControl.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import type { ComponentPropsWithRef } from "react"; -import { - Button as AriaButton, - ListBox, - ListBoxItem, - Popover, - Select, - SelectValue, -} from "react-aria-components"; -import { IconButton } from "./IconButton.tsx"; -import { AppIcon } from "./icons.ts"; -import { classNames } from "./classNames.ts"; -import "./LiveControl.css"; - -export type LiveIntervalSeconds = 30 | 60 | 300; - -const intervals = [ - { seconds: 30, label: "Every 30 seconds", description: "every 30 seconds" }, - { seconds: 60, label: "Every minute", description: "every minute" }, - { seconds: 300, label: "Every 5 minutes", description: "every 5 minutes" }, -] as const; - -export type LiveControlProps = { - enabled: boolean; - onChange: (enabled: boolean) => void; - intervalSeconds: LiveIntervalSeconds; - onIntervalChange: (seconds: LiveIntervalSeconds) => void; -} & Omit, "children" | "onClick" | "onChange">; - -/** Toggle an app-owned recurring refresh. The app supplies the timer and its cadence. */ -export function LiveControl({ - enabled, - onChange, - intervalSeconds, - onIntervalChange, - className, - ...props -}: LiveControlProps) { - const frequency = intervals.find((interval) => interval.seconds === intervalSeconds)?.description; - if (!frequency) throw new Error("LiveControl requires a supported update interval."); - return ( -
- onChange(!enabled)} - /> - -
- ); -} diff --git a/data-app/runtime/src/react/ui/MetricWidget.css b/data-app/runtime/src/react/ui/MetricWidget.css deleted file mode 100644 index b214960b..00000000 --- a/data-app/runtime/src/react/ui/MetricWidget.css +++ /dev/null @@ -1,81 +0,0 @@ -.altertable-metric-widget { - display: flex; - flex-direction: column; - min-width: 0; - padding: var(--at-space-md, 16px); - border: 1px solid var(--at-border, #e3e6e8); - border-radius: calc(var(--at-radius-surface, 12px) * 1.5); - corner-shape: squircle; - background: var(--at-surface, #fff); - box-shadow: var(--at-shadow-surface, none); -} -.altertable-metric-label { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--at-space-sm, 10px); - color: var(--at-muted, #687078); - font-size: 13px; - font-weight: 600; -} -.altertable-metric-value { - margin: var(--at-space-sm, 8px) 0 2px; - color: var(--at-text, #202124); - font-family: var(--at-font-heading, inherit); - font-size: clamp(26px, 3vw, 34px); - font-weight: 680; - line-height: 1.08; - letter-spacing: -0.04em; - font-variant-numeric: tabular-nums; -} -.altertable-metric-reading { - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: flex-start; - gap: 4px; -} -.altertable-metric-trend { - font-size: 12px; -} -.altertable-metric-change { - display: inline-flex; - align-items: center; - gap: 3px; - color: var(--at-muted, #687078); - font-variant-numeric: tabular-nums; -} -.altertable-metric-change[data-tone="good"] { - color: var(--at-accent, #405d47); -} -.altertable-metric-change[data-tone="bad"] { - color: var(--at-danger, #b42318); -} -.altertable-metric-help { - display: flex; - flex: none; - align-items: center; -} -.altertable-metric-description { - color: var(--at-muted, #687078); - font-size: 12px; - line-height: 1.45; -} -.altertable-metric-visual { - min-width: 0; - margin-top: var(--at-space-sm, 8px); -} -.altertable-metric-insight { - margin-top: var(--at-space-sm, 8px); - color: var(--at-muted, #687078); - font-size: 12px; - line-height: 1.45; -} -.altertable-metric-evidence { - display: grid; - gap: 10px; - min-width: 0; -} -.altertable-metric-evidence .altertable-metric-value { - margin: 0; -} diff --git a/data-app/runtime/src/react/ui/MetricWidget.tsx b/data-app/runtime/src/react/ui/MetricWidget.tsx deleted file mode 100644 index 56624236..00000000 --- a/data-app/runtime/src/react/ui/MetricWidget.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import type { ComponentPropsWithRef, ReactNode } from "react"; -import { AboutData } from "./AboutData.tsx"; -import type { WidgetEvidence } from "./WidgetEvidence.ts"; -import { AppIcon } from "./icons.ts"; -import { comparisonChange, type MetricComparison } from "./comparison.ts"; -import { classNames } from "./classNames.ts"; -import { formatMetric, type MetricFormat } from "../../core/format.ts"; -import { ContentSkeleton } from "./ContentSkeleton.tsx"; -import type { MetricReading } from "../../core/reading.ts"; -import { metricComparison, type MetricDefinition } from "./metric.ts"; -import "./Inspect.css"; -import "./MetricWidget.css"; - -type MetricWidgetBaseProps = { - label: string; - description?: ReactNode; - comparison?: MetricComparison; - evidence?: WidgetEvidence; - action?: ReactNode; - insight?: ReactNode; - visual?: ReactNode; -} & Omit, "about" | "children">; - -type UnboundMetricWidgetProps = MetricWidgetBaseProps & - ( - | { loading: true; value?: never; format?: never; content?: never } - | ({ loading?: false } & ( - | { value: number; format: MetricFormat; content?: never } - | { content: ReactNode; value?: never; format?: never } - )) - ); - -export type MetricWidgetProps = - | UnboundMetricWidgetProps - | (Omit & { - metric: MetricDefinition; - reading: MetricReading; - label?: never; - value?: never; - format?: never; - content?: never; - comparison?: never; - evidence?: never; - loading?: never; - }); - -export function MetricWidget(props: MetricWidgetProps) { - if ("metric" in props) { - const { metric, reading, ...rest } = props; - if (reading.loading) return ; - return ( - - ); - } - return ; -} - -function MetricWidgetContent({ - label, - value, - content, - format, - loading = false, - description, - comparison, - evidence, - action, - insight, - visual, - className, - ...props -}: UnboundMetricWidgetProps) { - if (loading) return ; - const shownValue = format ? formatMetric(value as number, format) : content; - const change = comparison ? comparisonChange(comparison) : null; - const shownTrend = - change?.percent != null ? ( - - - {Math.abs(change.percent).toFixed(1)}% vs{" "} - {comparison?.previous?.periodLabel?.toLowerCase() ?? "previous period"} - - ) : comparison?.previous?.value === null ? ( - - Previous period unavailable - - ) : null; - const help = evidence ? ( - -
- {shownValue} - {shownTrend && {shownTrend}} -
- {visual} - - } - visualKind="metric" - > - -
- ) : null; - return ( -
-
- {label} - {(action || help) && ( -
- {action} - {help} -
- )} -
-
- {shownValue} - {shownTrend && {shownTrend}} -
- {description && {description}} - {visual &&
{visual}
} - {insight &&
{insight}
} -
- ); -} diff --git a/data-app/runtime/src/react/ui/PeriodSummary.css b/data-app/runtime/src/react/ui/PeriodSummary.css deleted file mode 100644 index f76cb443..00000000 --- a/data-app/runtime/src/react/ui/PeriodSummary.css +++ /dev/null @@ -1,25 +0,0 @@ -.altertable-period-summary { - margin: 0; -} -.altertable-period-summary-visible { - display: inline-flex; - align-items: center; - flex-wrap: wrap; - gap: 4px 7px; - color: var(--at-muted, #687078); - font-size: 12px; - font-weight: 600; - font-variant-numeric: tabular-nums; - white-space: nowrap; -} -.altertable-period-summary-visible > svg, -.altertable-period-summary-vs { - color: var(--at-muted, #687078); -} -.altertable-period-summary-vs { - font-weight: 450; -} -.altertable-period-summary-current { - color: var(--at-text, #202124); - font-weight: 650; -} diff --git a/data-app/runtime/src/react/ui/PeriodSummary.tsx b/data-app/runtime/src/react/ui/PeriodSummary.tsx deleted file mode 100644 index 2e491ad0..00000000 --- a/data-app/runtime/src/react/ui/PeriodSummary.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { classNames } from "./classNames.ts"; -import { AppIcon } from "./icons.ts"; -import type { ReportingPeriod } from "../../core/contract.ts"; -import { formatDateRange } from "../../core/format.ts"; -import "./PeriodSummary.css"; - -export type { ReportingPeriod } from "../../core/contract.ts"; - -export type PeriodComparison = { kind: "previous" } | { kind: "period"; period: ReportingPeriod }; - -export type PeriodSummaryProps = { - period: ReportingPeriod; - comparison?: PeriodComparison; - className?: string; -}; - -function duration(period: Extract): string { - return `${period.amount} ${period.unit}${period.amount === 1 ? "" : "s"}`; -} - -function label(period: ReportingPeriod): string { - if (period.kind === "rolling") return `Last ${duration(period)}`; - return formatDateRange(period); -} - -function comparisonLabel(period: ReportingPeriod, comparison: PeriodComparison): string { - if (comparison.kind === "period") { - if (comparison.period.kind === "calendar") return label(comparison.period); - return `${duration(comparison.period)} ending ${new Date(comparison.period.end).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })}`; - } - return period.kind === "rolling" ? `Previous ${duration(period)}` : "Previous period"; -} - -export function reportingPeriodText(period: ReportingPeriod): string { - if (period.kind === "calendar") return `${label(period)} in ${period.timeZone}`; - const end = new Date(period.end); - if (Number.isNaN(end.getTime())) return label(period); - const start = new Date( - end.getTime() - period.amount * (period.unit === "hour" ? 3_600_000 : 86_400_000), - ); - return `${label(period)}, ${start.toISOString()} to ${end.toISOString()}`; -} - -/** Read-only period context for the variable bar, including exact bounds for assistive technology. */ -export function PeriodSummary({ period, comparison, className }: PeriodSummaryProps) { - const comparisonText = comparison ? comparisonLabel(period, comparison) : null; - const comparisonDetail = - comparison?.kind === "period" ? reportingPeriodText(comparison.period) : comparisonText; - return ( -

- - {`Reporting period: ${reportingPeriodText(period)}${comparisonDetail ? `, compared with ${comparisonDetail}` : ""}`} - - -

- ); -} diff --git a/data-app/runtime/src/react/ui/PlayStory.css b/data-app/runtime/src/react/ui/PlayStory.css deleted file mode 100644 index b2dee1b3..00000000 --- a/data-app/runtime/src/react/ui/PlayStory.css +++ /dev/null @@ -1,268 +0,0 @@ -:root:has(.altertable-present-dialog:modal) { - overflow: hidden; -} -.altertable-present-launch { - position: relative; - display: inline-flex; -} -.altertable-present-dialog:not([open]) { - display: none; -} -.altertable-present-dialog { - position: fixed; - inset: 0; - width: 100vw; - max-width: none; - height: 100dvh; - max-height: none; - margin: 0; - padding: 0; - border: 0; - background: var(--at-background, #fff); - color: var(--at-text, #202124); - overscroll-behavior: contain; - opacity: 0; - transition: - opacity 200ms ease, - overlay 200ms allow-discrete, - display 200ms allow-discrete; -} -.altertable-present-dialog[open] { - opacity: 1; -} -@starting-style { - .altertable-present-dialog[open] { - opacity: 0; - } -} -.altertable-present-dialog::backdrop { - background: transparent; -} -.altertable-present-dialog button:focus-visible { - outline: 2px solid var(--at-accent, #4779dc); - outline-offset: 2px; -} -.altertable-present-shell { - display: flex; - flex-direction: column; - height: 100%; - min-height: 0; -} -.altertable-present-header, -.altertable-present-nav { - display: flex; - flex: none; - align-items: center; - gap: var(--at-space-md, 16px); - width: min(100% - 48px, 1200px); - margin: 0 auto; -} -.altertable-present-header { - min-height: 60px; - border-bottom: 1px solid var(--at-border, #e3e6e8); - font-size: 13px; -} -.altertable-present-title { - display: flex; - flex: 1; - align-items: center; - flex-wrap: wrap; - gap: 3px 6px; - min-width: 0; - line-height: 24px; -} -.altertable-present-title strong { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-weight: 650; - line-height: 24px; -} -.altertable-present-connector { - color: var(--at-muted, #687078); - font-weight: 400; -} -.altertable-present-title .altertable-app-scope { - min-width: 0; - line-height: 24px; -} -.altertable-present-title .altertable-app-scope-environment { - line-height: 16px; -} -.altertable-present-count { - flex: none; - color: var(--at-muted, #687078); - font-size: 11px; - font-variant-numeric: tabular-nums; -} -.altertable-present-actions { - display: flex; - flex: none; - align-items: center; - gap: 4px; -} -.altertable-present-theme { - display: inline-flex; - margin-inline-start: var(--at-space-sm, 8px); -} -.altertable-present-main { - display: grid; - flex: 1; - grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); - align-content: safe center; - align-items: start; - column-gap: clamp(28px, 5vw, 72px); - row-gap: var(--at-space-lg, 24px); - width: min(100% - 64px, 1100px); - min-height: 0; - margin: 0 auto; - padding: var(--at-space-lg, 24px) 0; - overflow-y: auto; - overscroll-behavior-y: contain; - animation: altertable-present-step 260ms cubic-bezier(0.2, 0.7, 0.2, 1); -} -@keyframes altertable-present-step { - from { - opacity: 0; - translate: 0 6px; - } -} -.altertable-present-copy { - min-width: 0; -} -.altertable-present-copy > h2 { - margin: 0; - font-family: var(--at-font-heading, inherit); - font-size: clamp(30px, 3.8vw, 50px); - font-weight: 650; - line-height: 1.1; - letter-spacing: -0.04em; - overflow-wrap: anywhere; - text-wrap: balance; -} -.altertable-present-copy > h2:focus { - outline: none; -} -.altertable-present-explore { - grid-column: 1; - grid-row: 2; -} -.altertable-present-copy > p { - max-width: 58ch; - margin: var(--at-space-md, 16px) 0 0; - color: var(--at-muted, #687078); - font-size: clamp(15px, 1.5vw, 18px); - line-height: 1.55; - text-wrap: pretty; -} -.altertable-present-visual { - grid-column: 2; - grid-row: 1 / span 2; - display: flex; - align-items: center; - min-width: 0; - min-height: clamp(280px, 48vh, 470px); - max-height: 62vh; - padding: var(--at-space-lg, 24px); - overflow: auto; - overscroll-behavior: contain; - border: 1px solid var(--at-border, #e3e6e8); - border-radius: var(--at-radius-surface, 12px); - background: var(--at-surface, #fff); -} -.altertable-present-visual > * { - width: 100%; -} -.altertable-present-visual[data-kind="metric"] { - padding: 0; - border: 0; - background: transparent; -} -.altertable-present-nav { - justify-content: space-between; - min-height: 72px; -} -.altertable-present-nav > .altertable-tooltip-trigger > button:disabled { - color: var(--at-muted, #687078); -} -.altertable-present-dots { - display: flex; - align-items: center; -} -.altertable-present-dots button { - display: grid; - place-items: center; - width: 28px; - height: 44px; - padding: 0; - border: 0; - border-radius: var(--at-radius-control, 7px); - background: transparent; - cursor: pointer; -} -.altertable-present-dots button::before { - width: 7px; - height: 7px; - border-radius: 999px; - background: var(--at-border, #e3e6e8); - content: ""; - transition: - width 200ms cubic-bezier(0.2, 0.7, 0.2, 1), - background-color 200ms ease; -} -.altertable-present-dots button:hover::before { - background: var(--at-muted, #687078); -} -.altertable-present-dots button[aria-current="step"]::before { - width: 20px; - background: var(--at-accent, #4779dc); -} -@media (max-width: 800px) { - .altertable-present-main { - grid-template-columns: minmax(0, 1fr); - align-content: start; - gap: var(--at-space-lg, 24px); - width: min(100% - 40px, 640px); - } - .altertable-present-visual { - grid-column: 1; - grid-row: 2; - min-height: 220px; - max-height: none; - } - .altertable-present-explore { - grid-row: 3; - } -} -@media (max-width: 600px) { - .altertable-present-header, - .altertable-present-nav { - width: calc(100% - 32px); - } - .altertable-present-header { - min-height: 56px; - } - .altertable-present-main { - width: calc(100% - 32px); - padding: var(--at-space-md, 16px) 0; - } - .altertable-present-visual { - padding: var(--at-space-md, 16px); - } - .altertable-present-nav { - min-height: 64px; - gap: 0; - } - .altertable-present-dots button { - width: 24px; - } -} -@media (prefers-reduced-motion: reduce) { - .altertable-present-dialog, - .altertable-present-dots button::before { - transition: none; - } - .altertable-present-main { - animation: none; - } -} diff --git a/data-app/runtime/src/react/ui/PlayStory.tsx b/data-app/runtime/src/react/ui/PlayStory.tsx deleted file mode 100644 index fcd3870c..00000000 --- a/data-app/runtime/src/react/ui/PlayStory.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import { - useEffect, - useEffectEvent, - useId, - useRef, - useState, - type ComponentPropsWithRef, - type KeyboardEvent, - type ReactNode, -} from "react"; -import { useMergeRefs } from "@floating-ui/react"; -import type { DisclosedQuery } from "../../core/contract.ts"; -import type { ThemeController } from "../../core/appearance.ts"; -import { AboutData, type AboutEmpty } from "./AboutData.tsx"; -import type { DataContext } from "./data-context.ts"; -import { AppIcon } from "./icons.ts"; -import { IconButton } from "./IconButton.tsx"; -import { classNames } from "./classNames.ts"; -import { subscribeSearch, writeSearch } from "./search.ts"; -import { isEditingTarget, shortcuts, useShortcut } from "./shortcuts.ts"; -import { Tooltip } from "./Tooltip.tsx"; -import { ThemeToggle } from "./ThemeSelector.tsx"; -import "./PlayStory.css"; - -export type StoryStep = { - id: string; - empty?: AboutEmpty; - headline: string; - context?: string; - glossaryIds?: string[]; - queryNames?: string[]; - visual: ReactNode; - visualKind?: "metric" | "chart"; - queries?: DisclosedQuery[]; -}; - -export type PlayStoryProps = { - title: string; - steps: StoryStep[]; - empty?: AboutEmpty; - scope?: ReactNode; - dataContext: DataContext; - theme?: ThemeController; - launcherProps?: Omit, "children">; - dialogProps?: Omit, "children" | "aria-labelledby" | "open">; - headerActions?: ReactNode; - footer?: ReactNode; -} & Omit, "title">; - -const stepKeys: Record number> = { - ArrowRight: (index) => index + 1, - PageDown: (index) => index + 1, - ArrowLeft: (index) => index - 1, - PageUp: (index) => index - 1, - Home: () => 0, - End: (_, last) => last, -}; - -/** - * Uses an already loaded snapshot. Navigation is stored in `?present=1&step=`, and step - * inspection uses `?about=`. `launcherProps` targets the outer span; `dialogProps` targets the - * modal. - */ -export function PlayStory({ - title, - steps, - empty, - scope, - dataContext, - theme, - launcherProps, - dialogProps, - headerActions, - footer, - children, - className, - onClick, - disabled, - ref, - ...props -}: PlayStoryProps) { - const dialog = useRef(null); - const trigger = useRef(null); - const headline = useRef(null); - const headlineId = useId(); - const contextId = useId(); - const [stepId, setStepId] = useState(steps[0]?.id); - const triggerRef = useMergeRefs([trigger, ref]); - const dialogRef = useMergeRefs([dialog, dialogProps?.ref]); - const index = Math.max( - 0, - steps.findIndex((step) => step.id === stepId), - ); - const step = steps[index]; - const stepIds = steps.map((item) => item.id).join("\u0000"); - const unavailable = !steps.length || !!disabled; - const currentSteps = useEffectEvent(() => steps); - - useEffect(() => { - function syncFromUrl() { - const params = new URLSearchParams(window.location.search); - const availableSteps = currentSteps(); - if (params.get("present") === "1" && availableSteps.length) { - const requested = params.get("step"); - setStepId( - availableSteps.find((item) => item.id === requested)?.id ?? availableSteps[0]!.id, - ); - if (!dialog.current?.open) dialog.current?.showModal(); - } else if (dialog.current?.open) { - dialog.current.close(); - } - } - syncFromUrl(); - return subscribeSearch(syncFromUrl); - }, [stepIds]); - - useEffect(() => { - if (dialog.current?.open) headline.current?.focus({ preventScroll: true }); - }, [stepId]); - - function writeStory(id?: string, mode: "replace" | "push" = "replace") { - writeSearch({ present: id ? "1" : null, step: id ?? null }, mode); - } - - function open() { - if (unavailable) return; - setStepId(steps[0]!.id); - dialog.current?.showModal(); - headline.current?.focus({ preventScroll: true }); - writeStory(steps[0]!.id, "push"); - } - - useShortcut(shortcuts.playStory, open, !unavailable); - - function goTo(nextIndex: number) { - const next = steps[nextIndex]; - if (!next) return; - setStepId(next.id); - writeStory(next.id); - } - - function handleKeyDown(event: KeyboardEvent) { - if ( - !(event.target instanceof HTMLElement) || - event.target.closest("dialog") !== event.currentTarget - ) - return; - const move = stepKeys[event.key]; - if (!move || isEditingTarget(event.target) || event.altKey || event.ctrlKey || event.metaKey) - return; - event.preventDefault(); - goTo(move(index, steps.length - 1)); - } - - return ( - <> - - { - onClick?.(event); - if (!event.defaultPrevented) open(); - }} - > - {children} - - - { - if (event.target !== event.currentTarget) return; - if (new URLSearchParams(window.location.search).get("present") === "1") writeStory(); - trigger.current?.focus({ preventScroll: true }); - dialogProps?.onClose?.(event); - }} - onKeyDown={(event) => { - dialogProps?.onKeyDown?.(event); - if (!event.defaultPrevented) handleKeyDown(event); - }} - > - {step && ( -
-
-
- {title} - {scope && ( - <> - - {scope} - - )} -
-
- - {index + 1} / {steps.length} - - {theme && ( - - - - )} - {headerActions} - dialog.current?.close()} - /> -
-
-
-
-

- {step.headline} -

- {step.context &&

{step.context}

} -
-
- {step.visual} -
-
- - Explore - -
-
- - {footer} -
- )} -
- - ); -} diff --git a/data-app/runtime/src/react/ui/QueryList.css b/data-app/runtime/src/react/ui/QueryList.css deleted file mode 100644 index 84a56623..00000000 --- a/data-app/runtime/src/react/ui/QueryList.css +++ /dev/null @@ -1,154 +0,0 @@ -.altertable-query-list { - display: grid; - gap: 20px; - min-width: 0; -} -.altertable-query-list summary { - display: inline-flex; - align-items: center; - gap: 6px; - width: fit-content; - border-radius: 4px; - color: var(--at-muted, #687078); - font-size: 12px; - font-weight: 600; - list-style: none; - cursor: pointer; - transition: color 120ms ease; -} -.altertable-query-list summary::-webkit-details-marker { - display: none; -} -.altertable-query-list summary::before { - width: 0; - height: 0; - border-block: 4px solid transparent; - border-inline-start: 5px solid currentColor; - content: ""; - transition: rotate 120ms ease; -} -.altertable-query-list[open] summary::before { - rotate: 90deg; -} -.altertable-query-list summary:hover { - color: var(--at-text, #202124); -} -.altertable-query-list summary:focus-visible { - outline: 2px solid var(--at-accent, #4779dc); - outline-offset: 2px; -} -.altertable-query-list figure { - display: grid; - gap: 0; - margin: 0; - min-width: 0; - overflow: hidden; - border: 1px solid var(--at-border, #e3e6e8); - border-radius: 10px; - background: var(--at-surface, #fff); -} -.altertable-query-list figcaption { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - min-height: 36px; - padding: 3px 6px 3px 14px; - border-bottom: 1px solid var(--at-border, #e3e6e8); - background: transparent; - color: var(--at-text, #202124); - font: 600 12px/1.4 var(--at-font, system-ui, sans-serif); -} -.altertable-query-filename { - display: inline-flex; - align-items: center; - gap: 7px; - min-width: 0; - overflow-wrap: anywhere; -} -.altertable-query-actions { - display: inline-flex; - flex: none; - align-items: center; - gap: 2px; -} -.altertable-query-action { - width: 28px; - min-width: 28px; - min-height: 28px; - padding: 0; - border: 0; - background: none; - color: var(--at-muted, #687078); -} -.altertable-query-action:hover:not(:disabled) { - color: var(--at-text, #202124); - background: var(--at-subtle, #f6f7f7); -} -.altertable-query-action:focus-visible { - outline: 2px solid var(--at-accent, #4779dc); -} -.altertable-query-list pre { - margin: 0; - padding: 14px 16px 18px; - border: 0; - border-radius: 0; - background: var(--at-code-surface, #f4f6f9); - color: var(--at-code-text, #273242); - font: 12px/1.6 var(--at-mono-font, ui-monospace, SFMono-Regular, Consolas, monospace); - white-space: pre-wrap; - overflow-wrap: anywhere; - tab-size: 2; -} -.altertable-sql-keyword { - color: var(--at-accent, #305caf); - font-weight: 650; -} -.altertable-query-unavailable { - margin: 0; - color: var(--at-muted, #687078); - font-size: 13px; -} -.altertable-query-notebook { - gap: 16px; -} -.altertable-query-notebook-header { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 8px; - padding: 0 0 4px; -} -.altertable-query-notebook-header > span { - color: var(--at-muted, #687078); - font-size: 13px; - font-weight: 450; -} -.altertable-query-notebook-header small { - margin-inline-start: 8px; - color: var(--at-muted, #687078); - font-size: 11px; - font-weight: 500; -} -.altertable-query-notebook-header .altertable-button { - display: inline-flex; - align-items: center; - gap: 6px; - min-height: 30px; - font-size: 12px; -} -.altertable-query-notebook figure { - border-radius: 10px; -} -.altertable-query-notebook-error { - padding: 8px 12px; - color: var(--at-danger, #b42318); - font-size: 12px; -} -@media (prefers-reduced-motion: reduce) { - .altertable-query-list summary, - .altertable-query-list summary::before { - transition: none; - } -} diff --git a/data-app/runtime/src/react/ui/QueryList.tsx b/data-app/runtime/src/react/ui/QueryList.tsx deleted file mode 100644 index f9033dff..00000000 --- a/data-app/runtime/src/react/ui/QueryList.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { useEffect, useState, type ComponentPropsWithRef, type ReactNode } from "react"; -import type { DisclosedQuery } from "../../core/contract.ts"; -import { Button } from "./Button.tsx"; -import { classNames } from "./classNames.ts"; -import { AppIcon } from "./icons.ts"; -import { Tooltip } from "./Tooltip.tsx"; -import "./QueryList.css"; - -export type QueryListProps = { - queries?: DisclosedQuery[]; - names?: string[]; - summary?: ReactNode; - expanded?: boolean; -} & Omit, "children">; - -const KEYWORD = - /\b(WITH|SELECT|FROM|WHERE|AND|OR|NOT|IN|AS|ON|JOIN|LEFT|RIGHT|INNER|FULL|OUTER|CROSS|GROUP BY|ORDER BY|HAVING|LIMIT|UNION|ALL|DISTINCT|CASE|WHEN|THEN|ELSE|END|NULL|TRUE|FALSE|BETWEEN|LIKE|ILIKE|EXISTS|VALUES|CAST|COUNT|SUM|AVG|MIN|MAX|QUALIFY|WINDOW|OVER|PARTITION|BY)\b/gi; - -export function formatSql(statement: string): string { - return statement.trim(); -} - -function SqlCode({ statement }: { statement: string }) { - const formatted = formatSql(statement); - const parts = formatted.split(KEYWORD); - return ( - - {parts.map((part, index) => - index % 2 === 1 ? ( - - {part} - - ) : ( - part - ), - )} - - ); -} - -function QueryFigure({ name, statement }: { name: string; statement: string }) { - const [copyState, setCopyState] = useState<"idle" | "copied" | "error">("idle"); - useEffect(() => { - if (copyState === "idle") return; - const timer = window.setTimeout(() => setCopyState("idle"), 1500); - return () => window.clearTimeout(timer); - }, [copyState]); - async function copySql() { - try { - await navigator.clipboard.writeText(formatSql(statement)); - setCopyState("copied"); - } catch { - setCopyState("error"); - } - } - return ( -
-
- - {name.endsWith(".sql") ? name : `${name}.sql`} - - - - - - {copyState === "error" && Could not copy SQL} - -
-
-        
-      
-
- ); -} - -function QueryNotebook({ queries, className }: { queries: DisclosedQuery[]; className?: string }) { - const [copyState, setCopyState] = useState<"idle" | "copied" | "error">("idle"); - useEffect(() => { - if (copyState === "idle") return; - const timer = window.setTimeout(() => setCopyState("idle"), 1500); - return () => window.clearTimeout(timer); - }, [copyState]); - async function copyAll() { - const sql = queries - .map(({ name, statement }) => { - const formatted = formatSql(statement); - return `-- ${name.endsWith(".sql") ? name : `${name}.sql`}\n${formatted.endsWith(";") ? formatted : `${formatted};`}`; - }) - .join("\n\n"); - try { - await navigator.clipboard.writeText(sql); - setCopyState("copied"); - } catch { - setCopyState("error"); - } - } - return ( -
-
- - {queries.length} {queries.length === 1 ? "SQL query" : "SQL queries"} - - -
- {copyState === "error" && ( - Could not copy SQL - )} - {queries.map((query) => ( - - ))} -
- ); -} - -/** - * `names` selects supporting queries. Missing query evidence is shown explicitly; `expanded` - * skips the disclosure in a dedicated Queries view. - */ -export function QueryList({ - queries, - names, - summary = "Show SQL", - expanded = false, - className, - ...props -}: QueryListProps) { - const shown = names - ? (queries ?? []).filter((query) => names.includes(query.name)) - : (queries ?? []); - if (!shown.length) { - if (names?.length) - return

SQL was not included with this result.

; - return expanded ? ( -

No queries in this view.

- ) : null; - } - const figures = shown.map((query) => ( - - )); - if (expanded) return ; - return ( -
- {summary} - {figures} -
- ); -} diff --git a/data-app/runtime/src/react/ui/Ranking.css b/data-app/runtime/src/react/ui/Ranking.css deleted file mode 100644 index 826ae30a..00000000 --- a/data-app/runtime/src/react/ui/Ranking.css +++ /dev/null @@ -1,51 +0,0 @@ -.altertable-ranking { - display: grid; - gap: var(--at-space-md, 16px); - margin: 0; - padding: 0; - list-style: none; - min-width: 0; -} -.altertable-ranking li { - display: grid; - gap: 7px; - min-width: 0; -} -.altertable-ranking-reading { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; - min-width: 0; -} -.altertable-ranking-reading > span { - min-width: 0; - overflow-wrap: anywhere; - font-family: var(--at-font-heading, inherit); - font-weight: 600; - line-height: 1.3; - letter-spacing: -0.01em; -} -.altertable-ranking-reading strong { - flex: none; - font-family: var(--at-font-heading, inherit); - font-weight: 650; - font-variant-numeric: tabular-nums; -} -.altertable-ranking-track { - height: 6px; - overflow: hidden; - border-radius: 999px; - background: var(--at-subtle, #f6f7f7); -} -.altertable-ranking-track > span { - display: block; - height: 100%; - border-radius: inherit; - background: var(--at-accent, #405d47); -} -.altertable-ranking-detail { - color: var(--at-muted, #687078); - font-size: 12px; - line-height: 1.45; -} diff --git a/data-app/runtime/src/react/ui/Ranking.tsx b/data-app/runtime/src/react/ui/Ranking.tsx deleted file mode 100644 index d5c4bb36..00000000 --- a/data-app/runtime/src/react/ui/Ranking.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { ComponentPropsWithRef, ReactNode } from "react"; -import { formatCount } from "../../core/format.ts"; -import { classNames } from "./classNames.ts"; -import "./Ranking.css"; - -export type RankingItem = { id: string; label: ReactNode; value: number; detail?: ReactNode }; -export type RankingProps = { - items: readonly RankingItem[]; - formatValue?: (value: number) => ReactNode; -} & Omit, "children">; - -/** Ordered values scaled to the largest visible item, not shares of a whole. */ -export function Ranking({ items, formatValue = formatCount, className, ...props }: RankingProps) { - if (items.some((item) => !Number.isFinite(item.value) || item.value < 0)) - throw new Error("Ranking values must be finite and nonnegative."); - const max = Math.max(0, ...items.map((item) => item.value)); - return ( -
    - {items.map((item) => ( -
  1. -
    - {item.label} - {formatValue(item.value)} -
    - - {item.detail != null &&
    {item.detail}
    } -
  2. - ))} -
- ); -} diff --git a/data-app/runtime/src/react/ui/RefreshControl.css b/data-app/runtime/src/react/ui/RefreshControl.css deleted file mode 100644 index 927e3bf2..00000000 --- a/data-app/runtime/src/react/ui/RefreshControl.css +++ /dev/null @@ -1,50 +0,0 @@ -.altertable-refresh-control { - position: relative; - display: inline-flex; - align-items: center; - gap: var(--at-space-sm, 8px); -} -.altertable-refresh-loading { - display: none; -} -.altertable-refresh-cancel { - display: none; -} -.altertable-refresh-control[data-refreshing] .altertable-refresh-idle { - display: none; -} -.altertable-refresh-control[data-refreshing] .altertable-refresh-loading { - display: block; - animation: altertable-refresh-spin 900ms linear infinite; -} -.altertable-refresh-control[data-refreshing] - .altertable-button:has(.altertable-refresh-cancel):hover - .altertable-refresh-loading { - display: none; -} -.altertable-refresh-control[data-refreshing] .altertable-button:hover .altertable-refresh-cancel { - display: block; -} -.altertable-refresh-status { - color: var(--at-muted, #687078); - font-size: 11px; - white-space: nowrap; -} -.altertable-refresh-status:empty { - display: none; -} -@keyframes altertable-refresh-spin { - to { - rotate: 360deg; - } -} -@media (prefers-reduced-motion: reduce) { - .altertable-refresh-control[data-refreshing] .altertable-refresh-loading { - animation: altertable-refresh-pulse 1.2s ease-in-out infinite alternate; - } -} -@keyframes altertable-refresh-pulse { - to { - opacity: 0.35; - } -} diff --git a/data-app/runtime/src/react/ui/RefreshControl.tsx b/data-app/runtime/src/react/ui/RefreshControl.tsx deleted file mode 100644 index 93e7d00b..00000000 --- a/data-app/runtime/src/react/ui/RefreshControl.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { useEffect, useState, type ComponentPropsWithRef, type ReactNode } from "react"; -import { classNames } from "./classNames.ts"; -import "./RefreshControl.css"; - -export type RefreshControlProps = { - refreshing: boolean; - children: ReactNode; - label?: string; - statusProps?: Omit, "children" | "role">; - status?: ReactNode; -} & Omit, "children">; - -/** Show refresh progress just before its action after a short delay. */ -export function RefreshControl({ - refreshing, - children, - label = "Refreshing data", - statusProps, - status, - className, - ...props -}: RefreshControlProps) { - const [delay, setDelay] = useState({ refreshing, elapsed: false }); - if (delay.refreshing !== refreshing) setDelay({ refreshing, elapsed: false }); - - useEffect(() => { - if (!refreshing) return; - const timer = window.setTimeout(() => setDelay({ refreshing: true, elapsed: true }), 300); - return () => window.clearTimeout(timer); - }, [refreshing]); - const slow = refreshing && delay.refreshing && delay.elapsed; - - return ( -
-
- {slow && refreshing ? (status ?? label) : null} -
- {children} -
- ); -} diff --git a/data-app/runtime/src/react/ui/RefreshRegion.css b/data-app/runtime/src/react/ui/RefreshRegion.css deleted file mode 100644 index 10bda0f5..00000000 --- a/data-app/runtime/src/react/ui/RefreshRegion.css +++ /dev/null @@ -1,13 +0,0 @@ -.altertable-refresh-content { - transition: opacity 120ms ease; -} -.altertable-refresh-content[data-refreshing="true"] { - opacity: 0.55; - transition: opacity 240ms ease 300ms; -} -@media (prefers-reduced-motion: reduce) { - .altertable-refresh-content, - .altertable-refresh-content[data-refreshing="true"] { - transition: none; - } -} diff --git a/data-app/runtime/src/react/ui/RefreshRegion.tsx b/data-app/runtime/src/react/ui/RefreshRegion.tsx deleted file mode 100644 index 0175c0e0..00000000 --- a/data-app/runtime/src/react/ui/RefreshRegion.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { ComponentPropsWithRef, ReactNode } from "react"; -import { classNames } from "./classNames.ts"; -import "./RefreshRegion.css"; - -export type RefreshRegionProps = { - refreshing: boolean; - dimOnUpdate?: boolean; - children: ReactNode; - contentProps?: Omit, "children" | "aria-busy">; -} & Omit, "children">; - -/** Keep prior content in place while a fresh result loads. Local results can opt into dimming. */ -export function RefreshRegion({ - refreshing, - dimOnUpdate = false, - children, - contentProps, - className, - ...props -}: RefreshRegionProps) { - return ( -
-
- {children} -
-
- ); -} diff --git a/data-app/runtime/src/react/ui/SearchField.css b/data-app/runtime/src/react/ui/SearchField.css deleted file mode 100644 index 0b631b5c..00000000 --- a/data-app/runtime/src/react/ui/SearchField.css +++ /dev/null @@ -1,104 +0,0 @@ -.altertable-search-landmark { - min-width: 0; -} -.altertable-search { - display: grid; - gap: 7px; - min-width: 0; -} -.altertable-search-label { - font-size: 13px; - font-weight: 650; -} -.altertable-search-row { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} -.altertable-search-input-wrap { - display: flex; - align-items: center; - gap: 9px; - width: min(100%, 380px); - min-width: 0; - min-height: 44px; - padding: 0 12px; - border: 1px solid var(--at-border, #d9dee2); - border-radius: var(--at-radius-control, 8px); - background: var(--at-surface, #fff); - color: var(--at-muted, #626b73); -} -.altertable-search-input-wrap:hover { - border-color: color-mix(in srgb, var(--at-muted, #687078) 45%, var(--at-border, #e3e6e8)); -} -.altertable-search-input-wrap:focus-within { - border-color: var(--at-control-focus-border, var(--at-accent, #4779dc)); -} -.altertable-search[data-size="compact"] .altertable-search-input-wrap { - width: min(100%, 300px); - min-height: 34px; - padding: 0 10px; -} -.altertable-search[data-size="compact"] .altertable-search-input { - height: 32px; - font-size: 12px; -} -.altertable-search[data-size="compact"] .altertable-search-clear { - width: 32px; - height: 32px; -} -@media (max-width: 560px) { - .altertable-search[data-size="compact"] .altertable-search-input { - font-size: 16px; - } -} -.altertable-search-input { - flex: 1; - width: 100%; - min-width: 0; - height: 42px; - padding: 0; - border: 0; - outline: 0; - background: transparent; - color: var(--at-text, #202124); - font: inherit; - font-size: 14px; -} -.altertable-search-input::-webkit-search-cancel-button { - display: none; -} -.altertable-search-clear { - display: inline-grid; - place-items: center; - width: 44px; - height: 44px; - flex: none; - padding: 0; - border: 0; - border-radius: 6px; - background: transparent; - color: inherit; - cursor: pointer; -} -.altertable-search-clear:hover { - background: var(--at-hover, rgb(127 127 127 / 10%)); - color: var(--at-text, #202124); -} -.altertable-search-clear:focus-visible { - outline: 2px solid var(--at-accent, #4779dc); - outline-offset: 1px; -} -.altertable-search-description, -.altertable-search-status { - margin: 0; - color: var(--at-muted, #626b73); - font-size: 12px; - line-height: 1.45; -} -@media (max-width: 560px) { - .altertable-search-input-wrap { - width: 100%; - } -} diff --git a/data-app/runtime/src/react/ui/SearchField.tsx b/data-app/runtime/src/react/ui/SearchField.tsx deleted file mode 100644 index 8f2554c2..00000000 --- a/data-app/runtime/src/react/ui/SearchField.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { useId, type ComponentPropsWithRef, type ReactNode } from "react"; -import { classNames } from "./classNames.ts"; -import { AppIcon } from "./icons.ts"; -import "./SearchField.css"; - -export type SearchFieldProps = { - label: string; - value: string; - onChange: (value: string) => void; - onSubmit?: (value: string) => void; - placeholder?: string; - showLabel?: boolean; - size?: "default" | "compact"; - description?: ReactNode; - status?: ReactNode; - busy?: boolean; - resetValue?: string; - inputProps?: Omit, "type" | "value" | "children">; - clearButtonProps?: Omit, "type" | "children">; - children?: ReactNode; -} & Omit, "onChange" | "onSubmit" | "children">; - -/** - * The caller owns filtering, URL state, and whether search runs while typing or on submit. - * Escape and Clear reset the query. - */ -export function SearchField({ - label, - value, - onChange, - onSubmit, - placeholder, - showLabel = false, - size = "default", - description, - status, - busy, - resetValue = "", - inputProps, - clearButtonProps, - children, - className, - ...props -}: SearchFieldProps) { - const id = useId(); - const inputId = inputProps?.id ?? id; - const descriptionId = description ? `${id}-description` : undefined; - const statusId = status ? `${id}-status` : undefined; - return ( - -
{ - event.preventDefault(); - onSubmit?.(value); - }} - > - -
-
- - { - inputProps?.onChange?.(event); - if (!event.defaultPrevented) onChange(event.currentTarget.value); - }} - onKeyDown={(event) => { - inputProps?.onKeyDown?.(event); - if (!event.defaultPrevented && event.key === "Escape" && value !== resetValue) { - event.preventDefault(); - onChange(resetValue); - } - }} - /> - {value !== resetValue && ( - - )} -
- {children} -
- {description && ( -

- {description} -

- )} - {status && ( - - {status} - - )} - -
- ); -} diff --git a/data-app/runtime/src/react/ui/SearchMatch.css b/data-app/runtime/src/react/ui/SearchMatch.css deleted file mode 100644 index 2b118f90..00000000 --- a/data-app/runtime/src/react/ui/SearchMatch.css +++ /dev/null @@ -1,7 +0,0 @@ -.altertable-search-match mark { - background: color-mix(in srgb, currentColor 16%, transparent); - color: inherit; - border-radius: 2px; - box-decoration-break: clone; - padding: 0 1px; -} diff --git a/data-app/runtime/src/react/ui/SearchMatch.tsx b/data-app/runtime/src/react/ui/SearchMatch.tsx deleted file mode 100644 index 4c76468e..00000000 --- a/data-app/runtime/src/react/ui/SearchMatch.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { ComponentPropsWithRef } from "react"; -import { classNames } from "./classNames.ts"; -import type { SearchMatchValue } from "./searchItems.ts"; -import "./SearchMatch.css"; - -export type SearchMatchProps = { - match: SearchMatchValue; -} & Omit, "children">; - -export function SearchMatch({ match, className, ...props }: SearchMatchProps) { - const { text, ranges } = match; - const parts = []; - let position = 0; - for (const range of ranges) { - const start = Math.max(position, Math.min(text.length, range.start)); - const end = Math.max(start, Math.min(text.length, range.end)); - if (start > position) parts.push(text.slice(position, start)); - if (end > start) parts.push({text.slice(start, end)}); - position = end; - } - if (position < text.length) parts.push(text.slice(position)); - return ( - - {parts} - - ); -} diff --git a/data-app/runtime/src/react/ui/Sheet.css b/data-app/runtime/src/react/ui/Sheet.css deleted file mode 100644 index d70740f7..00000000 --- a/data-app/runtime/src/react/ui/Sheet.css +++ /dev/null @@ -1,192 +0,0 @@ -:root:has(.altertable-sheet:modal) { - overflow: hidden; -} -.altertable-sheet:not([open]) { - display: none; -} -.altertable-sheet { - position: fixed; - inset: 0 0 0 auto; - width: min(640px, 100vw); - height: 100dvh; - max-width: none; - max-height: none; - margin: 0; - padding: 0; - overflow: hidden; - border: 0; - border-left: 1px solid var(--at-border, #e3e6e8); - border-radius: 0; - background: var(--at-surface, #fff); - color: var(--at-text, #202124); - box-shadow: var(--at-shadow-overlay, 0 18px 50px rgb(20 28 40 / 16%)); - font: 14px/1.55 var(--at-font, ui-sans-serif, system-ui, sans-serif); - translate: 100% 0; - opacity: 0; - transition: - translate 260ms cubic-bezier(0.22, 1, 0.36, 1), - opacity 220ms ease; -} -.altertable-sheet[data-wide] { - width: min(960px, 100vw); -} -.altertable-sheet[open] { - translate: 0 0; - opacity: 1; -} -@starting-style { - .altertable-sheet[open] { - translate: 100% 0; - opacity: 0; - } -} -.altertable-sheet[open][data-closing] { - translate: 100% 0; - opacity: 0; - transition-timing-function: ease-in; -} -.altertable-sheet::backdrop { - background: var(--at-backdrop, rgb(15 23 30 / 22%)); - opacity: 0; - transition: opacity 220ms ease; -} -.altertable-sheet[open]::backdrop { - opacity: 1; -} -.altertable-sheet[open][data-closing]::backdrop { - opacity: 0; -} -@starting-style { - .altertable-sheet[open]::backdrop { - opacity: 0; - } -} -.altertable-sheet-shell { - display: grid; - grid-template-rows: auto minmax(0, 1fr) auto; - height: 100%; -} -.altertable-sheet-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - padding: 24px 28px 20px; - border-bottom: 1px solid var(--at-border, #e3e6e8); -} -.altertable-sheet-header h2 { - margin: 0; - font-size: 18px; - font-weight: 650; - line-height: 1.25; - letter-spacing: -0.02em; - overflow-wrap: anywhere; -} -.altertable-sheet-header p { - margin: 4px 0 0; - color: var(--at-muted, #687078); - font-size: 13px; - line-height: 1.45; -} -.altertable-sheet-actions { - display: flex; - align-items: center; - gap: 4px; - margin: -4px -8px 0 0; -} -.altertable-sheet-close { - display: grid; - place-items: center; - flex: none; - width: 36px; - height: 36px; - padding: 0; - border: 0; - border-radius: var(--at-radius-control, 7px); - background: transparent; - color: var(--at-muted, #687078); - cursor: pointer; - transition: - background-color 120ms ease, - color 120ms ease; -} -.altertable-sheet-close:hover { - background: var(--at-subtle, #f6f7f7); - color: var(--at-text, #202124); -} -.altertable-sheet-close:focus-visible { - outline: 2px solid var(--at-accent, #4779dc); - outline-offset: 2px; -} -.altertable-sheet-body { - min-height: 0; - overflow-y: auto; - overscroll-behavior: contain; - padding: 24px 28px 40px; -} -.altertable-sheet-footer { - padding: 16px 28px; - border-top: 1px solid var(--at-border, #e3e6e8); -} -@media (max-width: 960px) { - .altertable-sheet[data-wide] { - inset: auto 0 0; - border: 0; - box-shadow: none; - translate: 0 100%; - } - .altertable-sheet[data-wide][open] { - translate: 0 0; - } - .altertable-sheet[data-wide][open][data-closing] { - translate: 0 100%; - } - @starting-style { - .altertable-sheet[data-wide][open] { - translate: 0 100%; - } - } -} -@media (max-width: 600px) { - .altertable-sheet { - inset: auto 0 0; - border: 0; - box-shadow: none; - translate: 0 100%; - } - .altertable-sheet[open] { - translate: 0 0; - } - .altertable-sheet[open][data-closing] { - translate: 0 100%; - } - @starting-style { - .altertable-sheet[open] { - translate: 0 100%; - } - } - .altertable-sheet-header { - padding: 20px 20px 16px; - } - .altertable-sheet-body { - padding: 20px 20px 32px; - } - .altertable-sheet-footer { - padding: 14px 20px; - } -} -@media (prefers-reduced-motion: reduce) { - .altertable-sheet, - .altertable-sheet[open][data-closing] { - translate: 0 0; - transition: none; - } - .altertable-sheet::backdrop { - transition: none; - } - @starting-style { - .altertable-sheet[open] { - translate: 0 0; - } - } -} diff --git a/data-app/runtime/src/react/ui/Sheet.tsx b/data-app/runtime/src/react/ui/Sheet.tsx deleted file mode 100644 index 0e49a026..00000000 --- a/data-app/runtime/src/react/ui/Sheet.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { - useEffect, - useId, - useRef, - type ComponentPropsWithRef, - type ReactNode, - type RefObject, -} from "react"; -import { useMergeRefs } from "@floating-ui/react"; -import { classNames } from "./classNames.ts"; -import { AppIcon } from "./icons.ts"; -import "./Sheet.css"; - -export type SheetDialogProps = Omit< - ComponentPropsWithRef<"dialog">, - "children" | "open" | "title" | "aria-labelledby" ->; - -export type SheetProps = { - open: boolean; - onOpenChange: (open: boolean) => void; - title: ReactNode; - description?: ReactNode; - children: ReactNode; - headerActions?: ReactNode; - footer?: ReactNode; - wide?: boolean; - returnFocus?: RefObject; -} & SheetDialogProps; - -/** - * Uses the native dialog top layer and focus trap. Closing keeps the modal mounted until its - * exit transition completes. - */ -export function Sheet({ - open, - onOpenChange, - title, - description, - children, - headerActions, - footer, - wide, - returnFocus, - className, - onCancel, - onClose, - onClick, - onTransitionEnd, - ref, - ...props -}: SheetProps) { - const dialog = useRef(null); - const closeTimer = useRef | null>(null); - const titleId = useId(); - const descriptionId = useId(); - const dialogRef = useMergeRefs([dialog, ref]); - - useEffect(() => { - const element = dialog.current; - if (!element) return; - if (closeTimer.current) clearTimeout(closeTimer.current); - if (open) { - delete element.dataset.closing; - if (!element.open) element.showModal(); - } else if (element.open) { - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) element.close(); - else { - element.dataset.closing = ""; - // Keep the native modal mounted until its exit transition completes. - closeTimer.current = setTimeout(() => element.close(), 320); - } - } - return () => { - if (closeTimer.current) clearTimeout(closeTimer.current); - }; - }, [open]); - - // Native dialog backdrop clicks close the sheet; its contents remain keyboard accessible. - /* oxlint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */ - return ( - { - onCancel?.(event); - if (event.defaultPrevented) return; - event.preventDefault(); - onOpenChange(false); - }} - onTransitionEnd={(event) => { - onTransitionEnd?.(event); - if (event.defaultPrevented) return; - if ( - event.target !== event.currentTarget || - event.propertyName !== "translate" || - !("closing" in event.currentTarget.dataset) - ) - return; - if (closeTimer.current) clearTimeout(closeTimer.current); - event.currentTarget.close(); - }} - onClose={(event) => { - if (event.target !== event.currentTarget) return; - delete event.currentTarget.dataset.closing; - if (open) onOpenChange(false); - returnFocus?.current?.focus({ preventScroll: true }); - onClose?.(event); - }} - onClick={(event) => { - onClick?.(event); - if (event.defaultPrevented || event.target !== event.currentTarget) return; - if (event.clientX < event.currentTarget.getBoundingClientRect().left) onOpenChange(false); - }} - > -
-
-
-

{title}

- {description &&

{description}

} -
-
- {headerActions} - -
-
-
{children}
- {footer &&
{footer}
} -
-
- ); - /* oxlint-enable jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */ -} diff --git a/data-app/runtime/src/react/ui/Skeleton.css b/data-app/runtime/src/react/ui/Skeleton.css deleted file mode 100644 index d2a6751d..00000000 --- a/data-app/runtime/src/react/ui/Skeleton.css +++ /dev/null @@ -1,24 +0,0 @@ -.altertable-skeleton { - display: block; - border-radius: var(--at-radius-control, 7px); - background: var(--at-subtle, #f6f7f7); - opacity: 0; - animation: - altertable-skeleton-in 200ms ease 200ms forwards, - altertable-skeleton-pulse 1.4s ease-in-out 400ms infinite alternate; -} -@keyframes altertable-skeleton-in { - to { - opacity: 1; - } -} -@keyframes altertable-skeleton-pulse { - to { - opacity: 0.5; - } -} -@media (prefers-reduced-motion: reduce) { - .altertable-skeleton { - animation: altertable-skeleton-in 1ms 200ms forwards; - } -} diff --git a/data-app/runtime/src/react/ui/Skeleton.tsx b/data-app/runtime/src/react/ui/Skeleton.tsx deleted file mode 100644 index 5ec90b4d..00000000 --- a/data-app/runtime/src/react/ui/Skeleton.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { ComponentPropsWithRef } from "react"; -import { classNames } from "./classNames.ts"; -import "./Skeleton.css"; - -export type SkeletonProps = Omit, "children" | "aria-hidden">; - -/** Decorative loading placeholder that fades in only when loading takes a moment. - * Put the accessible status on its container. */ -export function Skeleton({ className, ...props }: SkeletonProps) { - return ( -
- - {columns.map((column) => ( - - ))} - - - - {visible.length === 0 ? ( - - ) : ( - visible.map((hit) => ( - - {columns.map((column, index) => - index === 0 ? ( - - ) : ( - - ), - )} - - )) - )} - - - {pageSize && hits.length > 0 && ( - - )} - - ); - return ( - -
{table}
-
- ); -} diff --git a/data-app/runtime/src/react/ui/Tabs.css b/data-app/runtime/src/react/ui/Tabs.css deleted file mode 100644 index cb2167b5..00000000 --- a/data-app/runtime/src/react/ui/Tabs.css +++ /dev/null @@ -1,55 +0,0 @@ -.react-aria-TabList { - display: flex; - gap: var(--at-space-lg, 24px); - overflow-x: auto; - overscroll-behavior-x: none; - border-bottom: 1px solid var(--at-border, #e3e6e8); - scrollbar-width: none; -} -.react-aria-Tab { - flex: none; - margin-bottom: -1px; - padding: 0 1px 12px; - border-bottom: 2px solid transparent; - color: var(--at-muted, #687078); - font-size: 13px; - font-weight: 550; - outline: none; - cursor: pointer; - transition: - color 120ms ease, - border-color 160ms ease; -} -.react-aria-Tab[data-hovered] { - color: var(--at-text, #202124); -} -.react-aria-Tab[data-selected] { - border-bottom-color: var(--at-accent, #4779dc); - color: var(--at-text, #202124); -} -.react-aria-Tab[data-focus-visible] { - outline: 2px solid var(--at-accent, #4779dc); - outline-offset: 3px; - border-radius: 2px; -} -.react-aria-Tab[data-disabled] { - opacity: 0.45; - cursor: default; -} -.react-aria-TabPanels { - min-width: 0; - padding-block-start: var(--at-space-md, 16px); -} -.react-aria-TabPanel { - min-width: 0; - outline: none; -} -.react-aria-TabPanel[data-focus-visible] { - outline: 2px solid var(--at-accent, #4779dc); - outline-offset: 3px; -} -@media (prefers-reduced-motion: reduce) { - .react-aria-Tab { - transition: none; - } -} diff --git a/data-app/runtime/src/react/ui/Tabs.tsx b/data-app/runtime/src/react/ui/Tabs.tsx deleted file mode 100644 index 7ae4265b..00000000 --- a/data-app/runtime/src/react/ui/Tabs.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { Tabs, TabList, Tab, TabPanels, TabPanel } from "react-aria-components/Tabs"; -import { searchParams, subscribeSearch, writeSearch } from "./search.ts"; -import "./Tabs.css"; - -/** React Aria tabs with keyboard and ARIA behavior; pair each Tab and TabPanel by stable id. */ -export { Tabs, TabList, Tab, TabPanels, TabPanel }; - -/** Keep a page-level tab in `?view=`; reserve `?tab=` for the inspect sheet. */ -export function useViewTab( - views: readonly View[], - fallback: View, -): [View, (key: string | number) => void] { - const currentView = useCallback((): View => { - const requested = searchParams().get("view") as View; - return views.includes(requested) ? requested : fallback; - }, [views, fallback]); - const [selected, setSelected] = useState(currentView); - useEffect(() => { - function sync() { - const view = currentView(); - setSelected(view); - if (searchParams().get("view") !== view) writeSearch({ view }); - } - sync(); - return subscribeSearch(sync); - }, [currentView]); - function select(key: string | number) { - const view = String(key) as View; - if (!views.includes(view)) return; - setSelected(view); - writeSearch({ view }, "push"); - } - return [selected, select]; -} diff --git a/data-app/runtime/src/react/ui/ThemeSelector.css b/data-app/runtime/src/react/ui/ThemeSelector.css deleted file mode 100644 index d86e0604..00000000 --- a/data-app/runtime/src/react/ui/ThemeSelector.css +++ /dev/null @@ -1,51 +0,0 @@ -.altertable-theme-selector { - display: inline-flex; - align-items: center; - gap: 2px; - margin: 0; - padding: 3px; - border: 0; - border-radius: var(--at-radius-control, 7px); - background: var(--at-subtle, #f6f7f7); -} -.altertable-theme-legend { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; -} -.altertable-theme-selector label { - position: relative; - display: grid; - place-items: center; - width: 30px; - height: 30px; - padding: 0; - border-radius: var(--at-radius-control, 7px); - background: transparent; - color: var(--at-muted, #687078); - cursor: pointer; -} -.altertable-theme-selector input { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - margin: 0; - opacity: 0; - cursor: pointer; -} -.altertable-theme-selector label:hover { - color: var(--at-text, #202124); -} -.altertable-theme-selector label:has(input:checked) { - background: var(--at-surface, #fff); - color: var(--at-text, #202124); - box-shadow: 0 1px 3px rgb(0 0 0 / 10%); -} -.altertable-theme-selector label:has(input:focus-visible) { - outline: 2px solid var(--at-accent, #4779dc); - outline-offset: 2px; -} diff --git a/data-app/runtime/src/react/ui/ThemeSelector.tsx b/data-app/runtime/src/react/ui/ThemeSelector.tsx deleted file mode 100644 index 936b1370..00000000 --- a/data-app/runtime/src/react/ui/ThemeSelector.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { - useCallback, - useId, - useSyncExternalStore, - type ComponentPropsWithRef, - type ReactNode, - type RefObject, -} from "react"; -import type { ThemeController } from "../../core/appearance.ts"; -import { IconButton } from "./IconButton.tsx"; -import { AppIcon } from "./icons.ts"; -import { classNames } from "./classNames.ts"; -import { Tooltip } from "./Tooltip.tsx"; -import "./ThemeSelector.css"; - -const modes = [ - { value: "light", label: "Light", icon: "lightTheme" }, - { value: "dark", label: "Dark", icon: "darkTheme" }, -] as const; - -export type ThemeSelectorProps = { - theme: ThemeController; - children?: ReactNode; -} & Omit, "children">; - -function useResolvedTheme(theme: ThemeController) { - const subscribe = useCallback( - (notify: () => void) => { - const preference = window.matchMedia("(prefers-color-scheme: dark)"); - const unsubscribe = theme.subscribe(notify); - preference.addEventListener("change", notify); - return () => { - unsubscribe(); - preference.removeEventListener("change", notify); - }; - }, - [theme], - ); - const snapshot = useCallback(() => { - const mode = theme.getMode(); - return mode === "system" - ? window.matchMedia("(prefers-color-scheme: dark)").matches - ? "dark" - : "light" - : mode; - }, [theme]); - return useSyncExternalStore(subscribe, snapshot, () => "light"); -} - -/** Viewer color preference. Brand tokens remain app-owned. */ -export function ThemeSelector({ theme, children, className, ...props }: ThemeSelectorProps) { - const selected = useResolvedTheme(theme); - const name = useId(); - return ( -
- Color theme - {modes.map(({ value, label, icon }) => ( - - - - ))} - {children} -
- ); -} - -export function ThemeToggle({ - theme, - portalRoot, -}: { - theme: ThemeController; - portalRoot?: RefObject; -}) { - const selected = useResolvedTheme(theme); - const next = selected === "dark" ? "light" : "dark"; - const icon = next === "dark" ? "darkTheme" : "lightTheme"; - return ( - theme.setMode(next)} - /> - ); -} diff --git a/data-app/runtime/src/react/ui/Tooltip.css b/data-app/runtime/src/react/ui/Tooltip.css deleted file mode 100644 index c1669046..00000000 --- a/data-app/runtime/src/react/ui/Tooltip.css +++ /dev/null @@ -1,47 +0,0 @@ -.altertable-tooltip-trigger { - display: inline-flex; - align-items: center; -} -.altertable-tooltip-trigger[data-variant="chart"] { - display: flex; - align-items: flex-end; - width: 100%; - height: 100%; - min-width: 0; -} -.altertable-tooltip-content { - z-index: 1000; - width: max-content; - max-width: min(240px, calc(100vw - 24px)); - padding: 6px 9px; - border: 1px solid var(--at-border, #e3e6e8); - border-radius: var(--at-radius-control, 7px); - background: var(--at-surface, #fff); - color: var(--at-text, #202124); - box-shadow: var(--at-shadow-overlay, 0 18px 50px rgb(20 28 40 / 16%)); - font: 500 12px/1.3 var(--at-font, ui-sans-serif, system-ui, sans-serif); - white-space: normal; - pointer-events: none; - animation: altertable-tooltip-in 120ms ease-out; -} -.altertable-tooltip-content[data-variant="chart"] { - display: grid; - gap: 2px; - padding: 8px 10px; - font-variant-numeric: tabular-nums; -} -.altertable-tooltip-content[data-variant="chart"] strong { - font-size: 14px; - font-weight: 650; - line-height: 1.2; -} -@keyframes altertable-tooltip-in { - from { - opacity: 0; - } -} -@media (prefers-reduced-motion: reduce) { - .altertable-tooltip-content { - animation: none; - } -} diff --git a/data-app/runtime/src/react/ui/Tooltip.tsx b/data-app/runtime/src/react/ui/Tooltip.tsx deleted file mode 100644 index 80c5e838..00000000 --- a/data-app/runtime/src/react/ui/Tooltip.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { - createContext, - useContext, - useEffect, - useEffectEvent, - useMemo, - useRef, - useState, - type ComponentPropsWithRef, - type ReactNode, - type RefObject, -} from "react"; -import { - autoUpdate, - flip, - FloatingPortal, - offset, - shift, - useFloating, - useMergeRefs, -} from "@floating-ui/react"; -import { classNames } from "./classNames.ts"; -import "./Tooltip.css"; - -type TooltipTiming = { hoverDelay: () => number; rememberClose: () => void }; -const TooltipContext = createContext(null); - -export type TooltipProviderProps = { - children: ReactNode; - delay?: number; - skipDelayDuration?: number; -}; - -/** Delay the first hover, then open nearby tooltips without waiting during the skip window. - * Keyboard focus always opens immediately. AppLayout provides this by default. */ -export function TooltipProvider({ - children, - delay = 500, - skipDelayDuration = 300, -}: TooltipProviderProps) { - const skipUntil = useRef(0); - const timing = useMemo( - () => ({ - hoverDelay: () => (Date.now() < skipUntil.current ? 0 : delay), - rememberClose: () => { - skipUntil.current = Date.now() + skipDelayDuration; - }, - }), - [delay, skipDelayDuration], - ); - return {children}; -} - -export type TooltipProps = { - content: ReactNode; - children: ReactNode; - variant?: "hint" | "chart"; - align?: "start" | "center" | "end"; - placement?: "top" | "bottom"; - portalRoot?: RefObject; - tooltipProps?: Omit, "children" | "role">; -} & Omit, "children" | "content">; - -/** Short visual hint for an already labeled control. variant="chart" follows the cursor - * on hover and toggles on tap so a bar or point can show the period and value. Hint - * tooltips hide once the control is pressed. Escape closes either. */ -export function Tooltip({ - content, - children, - variant = "hint", - align = "center", - placement, - portalRoot, - tooltipProps, - className, - ref, - onPointerEnter, - onPointerMove, - onPointerLeave, - onPointerDown, - onFocusCapture, - onBlurCapture, - ...props -}: TooltipProps) { - const timing = useContext(TooltipContext); - const [open, setOpen] = useState(false); - const [dialogRoot, setDialogRoot] = useState(null); - const hoverTimer = useRef | null>(null); - const suppressed = useRef(false); - const side = placement ?? (variant === "chart" ? "top" : "bottom"); - function cancelHover() { - if (hoverTimer.current !== null) clearTimeout(hoverTimer.current); - hoverTimer.current = null; - } - function close() { - cancelHover(); - if (open) timing?.rememberClose(); - setOpen(false); - } - const closeFromEffect = useEffectEvent(close); - useEffect(() => cancelHover, []); - useEffect(() => { - if (!open) return; - function onKeyDown(event: KeyboardEvent) { - if (event.key !== "Escape") return; - suppressed.current = true; - closeFromEffect(); - } - document.addEventListener("keydown", onKeyDown); - return () => document.removeEventListener("keydown", onKeyDown); - }, [open]); - const { refs, floatingStyles, update } = useFloating({ - open, - onOpenChange: setOpen, - placement: align === "center" ? side : `${side}-${align}`, - strategy: "fixed", - whileElementsMounted: autoUpdate, - middleware: [offset(variant === "chart" ? 16 : 8), flip(), shift({ padding: 12 })], - }); - const closeOnOutsidePointer = useEffectEvent((event: PointerEvent) => { - const node = event.target as Node; - if (refs.domReference.current?.contains(node) || refs.floating.current?.contains(node)) return; - close(); - }); - function followCursor(event: { clientX: number; clientY: number }) { - const { clientX: x, clientY: y } = event; - refs.setPositionReference({ - getBoundingClientRect: () => ({ - x, - y, - top: y, - left: x, - right: x, - bottom: y, - width: 0, - height: 0, - toJSON: () => ({}), - }), - }); - update(); - } - useEffect(() => { - if (!open || variant !== "chart") return; - function onDocPointerDown(event: PointerEvent) { - closeOnOutsidePointer(event); - } - document.addEventListener("pointerdown", onDocPointerDown); - return () => document.removeEventListener("pointerdown", onDocPointerDown); - }, [open, variant]); - const triggerRef = useMergeRefs([ - refs.setReference, - ref, - (element: HTMLSpanElement | null) => setDialogRoot(element?.closest("dialog") ?? null), - ]); - const floatingRef = useMergeRefs([refs.setFloating, tooltipProps?.ref]); - return ( - <> - { - onPointerEnter?.(event); - if (event.defaultPrevented || event.pointerType !== "mouse" || suppressed.current) return; - if (variant === "chart") followCursor(event); - if (open) return; - cancelHover(); - const delay = variant === "chart" ? 0 : (timing?.hoverDelay() ?? 500); - if (delay === 0) setOpen(true); - else - hoverTimer.current = setTimeout(() => { - hoverTimer.current = null; - setOpen(true); - }, delay); - }} - onPointerMove={(event) => { - onPointerMove?.(event); - if (event.defaultPrevented || variant !== "chart" || event.pointerType !== "mouse") - return; - followCursor(event); - }} - onPointerLeave={(event) => { - onPointerLeave?.(event); - suppressed.current = false; - if (variant === "chart" && event.pointerType !== "mouse") return; - if (!event.defaultPrevented && !event.currentTarget.contains(document.activeElement)) - close(); - else cancelHover(); - }} - onPointerDown={(event) => { - onPointerDown?.(event); - if (event.defaultPrevented) return; - if (variant === "chart" && event.pointerType !== "mouse") { - setOpen((wasOpen) => !wasOpen); - return; - } - suppressed.current = true; - close(); - }} - onFocusCapture={(event) => { - onFocusCapture?.(event); - if ( - event.defaultPrevented || - suppressed.current || - !event.target.matches(":focus-visible") - ) - return; - cancelHover(); - setOpen(true); - }} - onBlurCapture={(event) => { - onBlurCapture?.(event); - if (event.defaultPrevented || event.currentTarget.contains(event.relatedTarget)) return; - suppressed.current = false; - if (!event.currentTarget.matches(":hover")) close(); - }} - > - {children} - - {open && ( - - - {content} - - - )} - - ); -} diff --git a/data-app/runtime/src/react/ui/UpdatedAt.css b/data-app/runtime/src/react/ui/UpdatedAt.css deleted file mode 100644 index 6d2486c6..00000000 --- a/data-app/runtime/src/react/ui/UpdatedAt.css +++ /dev/null @@ -1,15 +0,0 @@ -.altertable-updated-at { - padding: 0; - border: 0; - background: transparent; - color: inherit; - font: inherit; - cursor: pointer; -} -.altertable-updated-at:hover { - color: var(--at-text, #202124); -} -.altertable-updated-at:focus-visible { - outline: 2px solid var(--at-accent, #4779dc); - outline-offset: 3px; -} diff --git a/data-app/runtime/src/react/ui/UpdatedAt.tsx b/data-app/runtime/src/react/ui/UpdatedAt.tsx deleted file mode 100644 index 7678c813..00000000 --- a/data-app/runtime/src/react/ui/UpdatedAt.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useEffect, useState, type ReactNode } from "react"; -import { DateTimeTooltip, type DateTimeTooltipProps } from "./DateTimeTooltip.tsx"; -import "./UpdatedAt.css"; - -export type UpdatedAtProps = { - timestamp: number; - locale?: string; - label?: string; - children?: ReactNode; -} & Omit; - -export function UpdatedAt({ - timestamp, - locale, - label = "Updated", - children, - triggerClassName, - ...props -}: UpdatedAtProps) { - const [now, setNow] = useState(Date.now); - useEffect(() => { - const interval = window.setInterval(() => setNow(Date.now()), 30_000); - return () => window.clearInterval(interval); - }, []); - - const elapsedMinutes = Math.floor(Math.max(0, now - timestamp) / 60_000); - const relative = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); - let age = "just now"; - if (elapsedMinutes >= 1_440) age = relative.format(-Math.floor(elapsedMinutes / 1_440), "day"); - else if (elapsedMinutes >= 60) age = relative.format(-Math.floor(elapsedMinutes / 60), "hour"); - else if (elapsedMinutes >= 1) age = relative.format(-elapsedMinutes, "minute"); - - const date = new Date(timestamp); - return ( - - {children ?? ( - - )} - - ); -} diff --git a/data-app/runtime/src/react/ui/VariableBar.css b/data-app/runtime/src/react/ui/VariableBar.css deleted file mode 100644 index 7d9984a8..00000000 --- a/data-app/runtime/src/react/ui/VariableBar.css +++ /dev/null @@ -1,11 +0,0 @@ -.altertable-variable-bar { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--at-space-sm, 8px); - min-width: 0; -} -.altertable-variable-bar > * { - max-width: 100%; - min-width: 0; -} diff --git a/data-app/runtime/src/react/ui/VariableBar.tsx b/data-app/runtime/src/react/ui/VariableBar.tsx deleted file mode 100644 index e9ebeab8..00000000 --- a/data-app/runtime/src/react/ui/VariableBar.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { ComponentPropsWithRef, ReactNode } from "react"; -import { classNames } from "./classNames.ts"; -import "./VariableBar.css"; - -export type VariableBarProps = { - children: ReactNode; -} & Omit, "children">; - -/** Reader-controlled inputs below the page header. */ -export function VariableBar({ - children, - className, - role = "group", - "aria-label": ariaLabel = "View variables", - ...props -}: VariableBarProps) { - return ( -
- {children} -
- ); -} diff --git a/data-app/runtime/src/react/ui/VisualizationWidget.css b/data-app/runtime/src/react/ui/VisualizationWidget.css deleted file mode 100644 index cfcbe88a..00000000 --- a/data-app/runtime/src/react/ui/VisualizationWidget.css +++ /dev/null @@ -1,7 +0,0 @@ -.altertable-visualization-widget-content { - min-width: 0; - padding: var(--at-space-md, 16px) var(--at-space-lg, 24px) var(--at-space-lg, 24px); -} -:where(.altertable-data-panel-body) > .altertable-visualization-widget-content { - padding: 0; -} diff --git a/data-app/runtime/src/react/ui/VisualizationWidget.tsx b/data-app/runtime/src/react/ui/VisualizationWidget.tsx deleted file mode 100644 index 333b6547..00000000 --- a/data-app/runtime/src/react/ui/VisualizationWidget.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { useState, type ComponentPropsWithRef, type ReactNode } from "react"; -import type { WidgetEvidence } from "./WidgetEvidence.ts"; -import { DataPanel, type DataPanelProps } from "./DataPanel.tsx"; -import type { EmptyStateProps } from "./EmptyState.tsx"; -import type { DataReading } from "../../core/reading.ts"; -import { ContentSkeleton, type ContentSkeletonProps } from "./ContentSkeleton.tsx"; -import { WidgetViewTabs } from "./WidgetViewTabs.tsx"; -import "./VisualizationWidget.css"; - -type VisualizationWidgetBaseProps = { - title: ReactNode; - description?: ReactNode; - insight?: ReactNode; - action?: ReactNode; - evidence?: WidgetEvidence; - /** Status for a secondary request shown beside this card's heading. */ - status?: DataPanelProps["status"]; - /** Valid result with nothing to draw, such as no rows matching a local filter. */ - empty?: Pick; -} & Omit, "about" | "title" | "children">; - -type UnboundVisualizationWidgetProps = VisualizationWidgetBaseProps & - ({ loading: true; visual?: never } | { loading?: false; visual: ReactNode }); - -export type VisualizationWidgetView = { - id: string; - label: ReactNode; - render: (data: Data) => ReactNode; -}; - -type BoundVisualizationWidgetBase = VisualizationWidgetBaseProps & { - evidence: WidgetEvidence; - reading: DataReading; - isEmpty: (data: Data) => boolean; - empty: Pick; - skeleton?: Pick; - visual?: never; - loading?: never; -}; - -export type VisualizationWidgetProps = - | UnboundVisualizationWidgetProps - | (BoundVisualizationWidgetBase & { children: (data: Data) => ReactNode; views?: never }) - | (BoundVisualizationWidgetBase & { - views: readonly VisualizationWidgetView[]; - viewLabel: string; - initialView?: string; - children?: never; - }); - -export function VisualizationWidget(props: VisualizationWidgetProps) { - if ("views" in props && props.views) return ; - if ("reading" in props) { - const { reading, children, isEmpty, empty, skeleton, ...rest } = props; - if (reading.loading) - return ; - const noData = isEmpty(reading.value); - return ( - - ); - } - return ; -} - -function VisualizationWidgetWithViews({ - reading, - views, - viewLabel, - initialView, - isEmpty, - empty, - skeleton, - ...rest -}: BoundVisualizationWidgetBase & { - views: readonly VisualizationWidgetView[]; - viewLabel: string; - initialView?: string; -}) { - const [selected, setSelected] = useState(initialView ?? views[0]?.id ?? ""); - if (reading.loading) - return ; - const noData = isEmpty(reading.value); - return ( - ({ - id: view.id, - label: view.label, - content: view.render(reading.value), - isEmpty: false, - empty, - }))} - selectedKey={selected} - onSelectionChange={setSelected} - /> - ) - } - /> - ); -} - -function VisualizationWidgetContent({ - title, - description, - visual, - loading = false, - insight, - action, - evidence, - status, - empty, - ...props -}: UnboundVisualizationWidgetProps) { - if (loading) return ; - return ( - -
{visual}
-
- ); -} diff --git a/data-app/runtime/src/react/ui/WidgetDisclosure.css b/data-app/runtime/src/react/ui/WidgetDisclosure.css deleted file mode 100644 index 228f517e..00000000 --- a/data-app/runtime/src/react/ui/WidgetDisclosure.css +++ /dev/null @@ -1,42 +0,0 @@ -.altertable-widget-disclosure { - margin: 16px 20px 20px; -} -.altertable-widget-disclosure > summary { - display: inline-flex; - align-items: center; - gap: 8px; - min-height: 38px; - padding: 0 10px; - border-radius: var(--at-radius-control, 7px); - color: var(--at-text, #202124); - font-size: 13px; - font-weight: 600; - cursor: pointer; - list-style: none; -} -.altertable-widget-disclosure > summary::-webkit-details-marker { - display: none; -} -.altertable-widget-disclosure > summary:hover, -.altertable-widget-disclosure[open] > summary { - background: var(--at-subtle, #f6f7f7); -} -.altertable-widget-disclosure > summary:focus-visible { - outline: 2px solid var(--at-accent, #4779dc); - outline-offset: 2px; -} -.altertable-widget-disclosure > summary svg { - color: var(--at-muted, #687078); - transition: transform 160ms ease; -} -.altertable-widget-disclosure[open] > summary svg { - transform: rotate(180deg); -} -.altertable-widget-disclosure-content { - margin-top: 12px; -} -@media (prefers-reduced-motion: reduce) { - .altertable-widget-disclosure > summary svg { - transition: none; - } -} diff --git a/data-app/runtime/src/react/ui/WidgetDisclosure.tsx b/data-app/runtime/src/react/ui/WidgetDisclosure.tsx deleted file mode 100644 index d09b36ee..00000000 --- a/data-app/runtime/src/react/ui/WidgetDisclosure.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { ComponentPropsWithRef, ReactNode } from "react"; -import { classNames } from "./classNames.ts"; -import { AppIcon } from "./icons.ts"; -import "./WidgetDisclosure.css"; - -export type WidgetDisclosureProps = { - label: ReactNode; - children: ReactNode; -} & Omit, "children">; - -export function WidgetDisclosure({ label, children, className, ...props }: WidgetDisclosureProps) { - return ( -
- - {label} - - -
{children}
-
- ); -} diff --git a/data-app/runtime/src/react/ui/WidgetEvidence.ts b/data-app/runtime/src/react/ui/WidgetEvidence.ts deleted file mode 100644 index 84480547..00000000 --- a/data-app/runtime/src/react/ui/WidgetEvidence.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { AboutSubject } from "./AboutData.tsx"; - -type EvidenceDetails = Omit< - AboutSubject, - "id" | "title" | "description" | "visual" | "glossaryIds" | "queryNames" -> & { id: string }; - -export type WidgetEvidence = EvidenceDetails & - ( - | { glossaryIds: [string, ...string[]]; queryNames?: string[] } - | { queryNames: [string, ...string[]]; glossaryIds?: string[] } - ); diff --git a/data-app/runtime/src/react/ui/WidgetViewTabs.css b/data-app/runtime/src/react/ui/WidgetViewTabs.css deleted file mode 100644 index 43be04e9..00000000 --- a/data-app/runtime/src/react/ui/WidgetViewTabs.css +++ /dev/null @@ -1,26 +0,0 @@ -.altertable-widget-view-tabs > .react-aria-Tabs > .react-aria-TabList { - width: fit-content; - max-width: 100%; - margin: 0; - gap: 4px; - padding: 3px; - border: 0; - border-radius: var(--at-radius-control, 7px); - background: var(--at-subtle, #f6f7f7); -} -.altertable-widget-view-tabs .react-aria-Tab { - margin: 0; - padding: 6px 11px; - border: 0; - border-radius: calc(var(--at-radius-control, 7px) - 2px); - color: var(--at-muted, #687078); - font-size: 12px; -} -.altertable-widget-view-tabs .react-aria-Tab[data-selected] { - background: var(--at-surface, #fff); - box-shadow: 0 1px 3px rgb(0 0 0 / 8%); - color: var(--at-text, #202124); -} -.altertable-widget-view-tabs .react-aria-TabPanels { - padding-block-start: var(--at-space-md, 16px); -} diff --git a/data-app/runtime/src/react/ui/WidgetViewTabs.tsx b/data-app/runtime/src/react/ui/WidgetViewTabs.tsx deleted file mode 100644 index f8f24f6b..00000000 --- a/data-app/runtime/src/react/ui/WidgetViewTabs.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { ComponentPropsWithRef, ReactNode } from "react"; -import { Tab, TabList, TabPanel, TabPanels, Tabs } from "./Tabs.tsx"; -import { EmptyState, type EmptyStateProps } from "./EmptyState.tsx"; -import { classNames } from "./classNames.ts"; -import "./WidgetViewTabs.css"; - -export type WidgetView = { - id: string; - label: ReactNode; - content: ReactNode; - empty: Pick; - isEmpty: boolean; -}; -export type WidgetViewTabsProps = { - label: string; - views: Views; - selectedKey: NoInfer; - onSelectionChange: (key: Views[number]["id"]) => void; -} & Omit, "children">; - -export function WidgetViewTabs({ - label, - views, - selectedKey, - onSelectionChange, - className, - ...props -}: WidgetViewTabsProps) { - const ids = new Set(views.map((view) => view.id)); - if (ids.size !== views.length || views.some((view) => !view.id.trim())) - throw new Error("Widget tab IDs must be nonempty and unique."); - if (!ids.has(selectedKey)) throw new Error(`Unknown widget tab: ${selectedKey}.`); - return ( -
- onSelectionChange(String(key))}> - - {views.map((view) => ( - - {view.label} - - ))} - - - {views.map((view) => ( - - {view.isEmpty ? : view.content} - - ))} - - -
- ); -} diff --git a/data-app/runtime/src/react/ui/chartColor.ts b/data-app/runtime/src/react/ui/chartColor.ts deleted file mode 100644 index 6fc54f2d..00000000 --- a/data-app/runtime/src/react/ui/chartColor.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function chartColor(id: string): string { - let hash = 2166136261; - for (const codePoint of id) hash = Math.imul(hash ^ codePoint.codePointAt(0)!, 16777619); - return `var(--at-chart-${((hash >>> 0) % 8) + 1}, var(--at-accent, #4779dc))`; -} diff --git a/data-app/runtime/src/react/ui/classNames.ts b/data-app/runtime/src/react/ui/classNames.ts deleted file mode 100644 index d26605b9..00000000 --- a/data-app/runtime/src/react/ui/classNames.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function classNames(...values: Array): string { - return values.filter(Boolean).join(" "); -} diff --git a/data-app/runtime/src/react/ui/comparison.ts b/data-app/runtime/src/react/ui/comparison.ts deleted file mode 100644 index 373c8248..00000000 --- a/data-app/runtime/src/react/ui/comparison.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { DateRangeRequest } from "../../core/contract.ts"; -import { formatDateRange, formatMetric, type MetricFormat } from "../../core/format.ts"; - -export type MetricComparison = { - current: { value: number; formattedValue: string; periodLabel?: string }; - previous?: { value: number | null; formattedValue: string; periodLabel?: string }; - favorableDirection?: "up" | "down"; -}; - -/** Label a comparison from the input that produced the displayed result. Null means the - * requested previous period has no comparable value; zero remains a measured value. */ -export function calendarMetricComparison( - displayedInput: DateRangeRequest, - values: { - current: number; - previous: number | null; - format: MetricFormat; - favorableDirection?: MetricComparison["favorableDirection"]; - }, -): MetricComparison | undefined { - if (!displayedInput.comparison) return undefined; - return { - current: { - value: values.current, - formattedValue: formatMetric(values.current, values.format), - periodLabel: formatDateRange(displayedInput.range), - }, - previous: { - value: values.previous, - formattedValue: - values.previous === null ? "Not available" : formatMetric(values.previous, values.format), - periodLabel: formatDateRange(displayedInput.comparison), - }, - favorableDirection: values.favorableDirection, - }; -} - -/** A zero or missing baseline has no meaningful relative percentage. */ -export function comparisonChange({ current, previous, favorableDirection }: MetricComparison) { - const percent = previous?.value ? (current.value / previous.value - 1) * 100 : null; - const direction = - percent === null || Math.abs(percent) < 0.05 ? "flat" : percent > 0 ? "up" : "down"; - const tone = - direction === "flat" || !favorableDirection - ? "neutral" - : direction === favorableDirection - ? "good" - : "bad"; - const icon = direction === "up" ? "trendUp" : direction === "down" ? "trendDown" : "trendFlat"; - return { percent, direction, tone, icon } as const; -} diff --git a/data-app/runtime/src/react/ui/data-context.ts b/data-app/runtime/src/react/ui/data-context.ts deleted file mode 100644 index 66565e17..00000000 --- a/data-app/runtime/src/react/ui/data-context.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { defineQueryNames } from "../../core/contract.ts"; -import type { ReactNode } from "react"; -import type { DataIdentifierDefinition } from "./data-identifiers.tsx"; -import type { MetricFormat } from "../../core/format.ts"; -import type { MetricDefinition } from "./metric.ts"; -import type { StoryStep } from "./PlayStory.tsx"; -import type { WidgetEvidence } from "./WidgetEvidence.ts"; - -export type GlossaryEntry = { - term: string; - definition: ReactNode; - queryNames?: string[]; -}; - -export type DataContext = { - description: ReactNode; - glossary: Record; - identifiers?: Readonly>; -}; - -export function defineDataContext(context: Context): Context { - return context; -} - -export function evidenceFor< - const Context extends DataContext, - const Names extends Record = Record, ->(context: Context, names?: Names) { - return (evidence: { - id: string; - glossaryIds?: readonly (keyof Context["glossary"] & string)[]; - queryNames?: readonly Names[keyof Names][]; - }) => { - for (const id of evidence.glossaryIds ?? []) { - if (!context.glossary[id]) throw new Error(`Unknown glossary entry: ${id}.`); - } - if (names) - for (const name of evidence.queryNames ?? []) { - if (!Object.values(names).includes(name)) throw new Error(`Unknown query name: ${name}.`); - } - return { - id: evidence.id, - glossaryIds: evidence.glossaryIds ? [...evidence.glossaryIds] : undefined, - queryNames: evidence.queryNames ? [...evidence.queryNames] : undefined, - }; - }; -} - -/** Bind glossary query references and card evidence to the same registry. */ -export function createDataContext>(queryNames: Names) { - defineQueryNames(queryNames); - return < - const Context extends Omit & { - glossary: Record< - string, - Omit & { queryNames?: Names[keyof Names][] } - >; - }, - >( - context: Context, - ) => { - const known = new Set(Object.values(queryNames)); - for (const [id, entry] of Object.entries(context.glossary)) { - for (const query of entry.queryNames ?? []) { - if (!known.has(query)) throw new Error(`Unknown query ${query} for glossary entry ${id}.`); - } - } - const references = evidenceFor(context, queryNames); - type GlossaryId = keyof Context["glossary"] & string; - type QueryName = Names[keyof Names]; - function evidence( - input: { id: string } & ( - | { glossaryIds: readonly [GlossaryId, ...GlossaryId[]]; queryNames?: readonly QueryName[] } - | { queryNames: readonly [QueryName, ...QueryName[]]; glossaryIds?: readonly GlossaryId[] } - ), - ): WidgetEvidence { - if (!input.id.trim()) throw new Error("Evidence needs a nonempty ID."); - if (!input.glossaryIds?.length && !input.queryNames?.length) - throw new Error(`Evidence ${input.id} needs a glossary entry or query name.`); - const validated = references(input); - if (validated.glossaryIds?.length) - return { - ...validated, - glossaryIds: [validated.glossaryIds[0]!, ...validated.glossaryIds.slice(1)], - }; - return { - ...validated, - queryNames: [validated.queryNames![0]!, ...validated.queryNames!.slice(1)], - }; - } - function storyStep< - const Step extends Omit & { - glossaryIds?: readonly (keyof Context["glossary"] & string)[]; - queryNames?: readonly Names[keyof Names][]; - }, - >(step: Step): StoryStep { - const stepReferences = references({ - id: step.id, - glossaryIds: step.glossaryIds, - queryNames: step.queryNames, - }); - return { ...step, ...stepReferences }; - } - function metric(definition: { - id: string; - glossaryId: keyof Context["glossary"] & string; - label?: string; - format: MetricFormat; - favorableDirection?: "up" | "down"; - queryNames?: readonly Names[keyof Names][]; - }): MetricDefinition { - const references = evidence({ - id: definition.id, - glossaryIds: [definition.glossaryId], - queryNames: definition.queryNames ?? context.glossary[definition.glossaryId]?.queryNames, - }); - return { - id: definition.id, - label: definition.label ?? context.glossary[definition.glossaryId]!.term, - format: definition.format, - favorableDirection: definition.favorableDirection, - evidence: references, - }; - } - return { ...context, queryNames, evidence, storyStep, metric }; - }; -} diff --git a/data-app/runtime/src/react/ui/data-identifiers.css b/data-app/runtime/src/react/ui/data-identifiers.css deleted file mode 100644 index 054c0a33..00000000 --- a/data-app/runtime/src/react/ui/data-identifiers.css +++ /dev/null @@ -1,20 +0,0 @@ -.altertable-data-identifier { - padding: 0.06em 0.24em 0.08em; - border: 1px solid color-mix(in srgb, var(--at-border, #e3e6e8) 65%, var(--at-surface, #fff)); - border-radius: 0.3em; - background: color-mix(in srgb, var(--at-subtle, #f6f7f7) 65%, var(--at-surface, #fff)); - color: var(--at-text, #202124); - font-size: 0.85em; - font-weight: 450; - line-height: inherit; - letter-spacing: -0.01em; - overflow-wrap: anywhere; - box-decoration-break: clone; - -webkit-box-decoration-break: clone; -} -.altertable-data-identifier-path { - color: var(--at-muted, #687078); -} -.altertable-data-identifier-name { - font-weight: 550; -} diff --git a/data-app/runtime/src/react/ui/data-identifiers.tsx b/data-app/runtime/src/react/ui/data-identifiers.tsx deleted file mode 100644 index 9053116f..00000000 --- a/data-app/runtime/src/react/ui/data-identifiers.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import "./data-identifiers.css"; - -export type TableIdentifier = { catalog: string; schema: string; name: string }; -export type ColumnIdentifier = { - table: TableId; - name: string; -}; - -export type DataIdentifierDefinition = - | ({ kind: "table" } & TableIdentifier) - | { kind: "column"; tableId: `tables.${string}`; table: TableIdentifier; name: string }; - -type IdentifierId< - Tables extends Record, - Columns extends Record>>, -> = - | `tables.${Extract}` - | { - [Id in Extract]: `columns.${Columns[Id]["table"]}.${Id}`; - }[Extract]; - -function qualifiedName(definition: DataIdentifierDefinition): string { - const table = definition.kind === "table" ? definition : definition.table; - const path = [table.catalog, table.schema, table.name].join("."); - return definition.kind === "column" ? `${path}.${definition.name}` : path; -} - -function assertAlias(id: string) { - if (!id || id.includes(".")) throw new Error(`Invalid data identifier alias ${id}.`); -} - -/** Register exact source names once; returned JSX IDs are checked against this registry. */ -export function defineDataIdentifiers< - const Tables extends Record, - const Columns extends Record>>, ->({ tables, columns }: { tables: Tables; columns: Columns }) { - type Id = IdentifierId; - const definitions: Record = Object.create(null); - - for (const [id, table] of Object.entries(tables)) { - assertAlias(id); - definitions[`tables.${id}`] = { kind: "table", ...table }; - } - for (const [id, column] of Object.entries(columns)) { - assertAlias(id); - const table = tables[column.table]; - if (!table) throw new Error(`Unknown table ${column.table} for column ${id}.`); - definitions[`columns.${column.table}.${id}`] = { - kind: "column", - tableId: `tables.${column.table}`, - table, - name: column.name, - }; - } - - function DataIdentifier({ id, display }: { id: Id; display?: "short" | "qualified" }) { - const definition = definitions[id]; - if (!definition) throw new Error(`Unknown data identifier ${id}.`); - const qualified = qualifiedName(definition); - const shown = display ?? (definition.kind === "table" ? "qualified" : "short"); - const table = definition.kind === "table" ? definition : definition.table; - const prefix = - definition.kind === "table" - ? [table.catalog, table.schema] - : [table.catalog, table.schema, table.name]; - return ( - - {shown === "qualified" && ( - - {prefix.map((segment, index) => ( - - {segment}. - - ))} - - )} - {definition.name} - - ); - } - - return { - definitions: definitions as Readonly>, - DataIdentifier, - }; -} diff --git a/data-app/runtime/src/react/ui/icons.ts b/data-app/runtime/src/react/ui/icons.ts deleted file mode 100644 index b47c5bd8..00000000 --- a/data-app/runtime/src/react/ui/icons.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { createElement, type CSSProperties } from "react"; -import { - ArrowDownRight, - ArrowLeft, - ArrowRight, - ArrowUpRight, - BookOpenText, - Check, - CalendarDays, - ChevronDown, - ChevronLeft, - ChevronRight, - CircleAlert, - Clock3, - Copy, - FileCode2, - Info, - LoaderCircle, - Minus, - Moon, - PanelRightOpen, - Presentation, - Radio, - RotateCw, - Search, - Sun, - TextWrap, - X, - type LucideProps, -} from "lucide-react"; - -/** Use semantic names to keep icon meaning and optical size consistent across controls. */ -const brandedIcons = { - cancel: X, - calendar: CalendarDays, - clock: Clock3, - check: Check, - disclosure: ChevronDown, - previousMonth: ChevronLeft, - nextMonth: ChevronRight, - explore: BookOpenText, - inspect: PanelRightOpen, - info: Info, - openDetails: ChevronRight, - present: Presentation, - error: CircleAlert, - close: X, - previous: ArrowLeft, - next: ArrowRight, - lightTheme: Sun, - darkTheme: Moon, - live: Radio, - loading: LoaderCircle, - refresh: RotateCw, - reset: X, - search: Search, - copy: Copy, - sql: FileCode2, - wrap: TextWrap, - trendDown: ArrowDownRight, - trendFlat: Minus, - trendUp: ArrowUpRight, -} as const; - -export type AppIconName = keyof typeof brandedIcons; -export type AppIconProps = Omit & { name: AppIconName; size?: number }; - -export function AppIcon({ name, size = 18, strokeWidth = 1.8, style, ...props }: AppIconProps) { - const Icon = brandedIcons[name]; - const fixedSize: CSSProperties = { - ...style, - width: size, - height: size, - minWidth: size, - minHeight: size, - maxWidth: size, - maxHeight: size, - flexShrink: 0, - }; - return createElement(Icon, { - "aria-hidden": true, - focusable: "false", - ...props, - size, - strokeWidth, - style: fixedSize, - }); -} diff --git a/data-app/runtime/src/react/ui/index.ts b/data-app/runtime/src/react/ui/index.ts deleted file mode 100644 index 8c4b426a..00000000 --- a/data-app/runtime/src/react/ui/index.ts +++ /dev/null @@ -1,164 +0,0 @@ -// App shell and getting started -export { DataApp } from "./DataApp.tsx"; -export type { DataAppProps, DataAppRequest } from "./DataApp.tsx"; -export { GettingStarted } from "./GettingStarted.tsx"; -export { AppLayout } from "./AppLayout.tsx"; -export type { AppLayoutProps } from "./AppLayout.tsx"; -export { AppHeader } from "./AppHeader.tsx"; -export type { AppHeaderProps } from "./AppHeader.tsx"; -export { AppScope } from "./AppScope.tsx"; -export type { AppScopeProps } from "./AppScope.tsx"; -export { AppToolbar } from "./AppToolbar.tsx"; -export type { AppToolbarProps } from "./AppToolbar.tsx"; -export { VariableBar } from "./VariableBar.tsx"; -export type { VariableBarProps } from "./VariableBar.tsx"; -export { AppFooter } from "./AppFooter.tsx"; -export type { AppFooterProps } from "./AppFooter.tsx"; -export { ThemeSelector, ThemeToggle } from "./ThemeSelector.tsx"; -export type { ThemeSelectorProps } from "./ThemeSelector.tsx"; - -// Layout -export { Stack } from "./Stack.tsx"; -export type { StackProps } from "./Stack.tsx"; -export { Grid } from "./Grid.tsx"; -export type { GridProps } from "./Grid.tsx"; -export { GridItem } from "./GridItem.tsx"; -export type { GridItemProps } from "./GridItem.tsx"; -export { StorySection } from "./StorySection.tsx"; -export type { StorySectionProps } from "./StorySection.tsx"; - -// Widgets and visualizations -export type { WidgetEvidence } from "./WidgetEvidence.ts"; -export { VisualizationWidget } from "./VisualizationWidget.tsx"; -export type { VisualizationWidgetProps, VisualizationWidgetView } from "./VisualizationWidget.tsx"; -export { TableWidget } from "./TableWidget.tsx"; -export type { TableWidgetColumn, TableWidgetProps, TableWidgetSearch } from "./TableWidget.tsx"; -export { chartColor } from "./chartColor.ts"; -export { Breakdown } from "./Breakdown.tsx"; -export type { BreakdownItem, BreakdownProps } from "./Breakdown.tsx"; -export { Ranking } from "./Ranking.tsx"; -export type { RankingItem, RankingProps } from "./Ranking.tsx"; -export { WidgetDisclosure } from "./WidgetDisclosure.tsx"; -export type { WidgetDisclosureProps } from "./WidgetDisclosure.tsx"; -export { WidgetViewTabs } from "./WidgetViewTabs.tsx"; -export type { WidgetView, WidgetViewTabsProps } from "./WidgetViewTabs.tsx"; -export { ComparisonVisual } from "./ComparisonVisual.tsx"; -export type { ComparisonVisualProps } from "./ComparisonVisual.tsx"; -export { calendarMetricComparison } from "./comparison.ts"; -export type { MetricComparison } from "./comparison.ts"; -export { DataTable, DataTableEmptyRow, DataTableTimestamp, DataTableShare } from "./DataTable.tsx"; -export type { - DataTableProps, - DataTableSearch, - DataTableEmptyRowProps, - DataTableTimestampProps, -} from "./DataTable.tsx"; -export { MetricWidget } from "./MetricWidget.tsx"; -export type { MetricWidgetProps } from "./MetricWidget.tsx"; - -// Request states and freshness -export { EmptyState } from "./EmptyState.tsx"; -export type { EmptyStateProps } from "./EmptyState.tsx"; -export { Skeleton } from "./Skeleton.tsx"; -export type { SkeletonProps } from "./Skeleton.tsx"; -export { DataBoundary } from "./DataBoundary.tsx"; -export { resolveDataView } from "../../core/data-view.ts"; -export type { DataView, DataSnapshot } from "../../core/data-view.ts"; -export type { DataBoundaryProps } from "./DataBoundary.tsx"; -export { RefreshRegion } from "./RefreshRegion.tsx"; -export type { RefreshRegionProps } from "./RefreshRegion.tsx"; -export { DataSection } from "./DataSection.tsx"; -export type { DataSectionProps } from "./DataSection.tsx"; -export { StatusPanel } from "./StatusPanel.tsx"; -export type { StatusPanelProps } from "./StatusPanel.tsx"; -export { ContentSkeleton } from "./ContentSkeleton.tsx"; -export type { ContentSkeletonProps } from "./ContentSkeleton.tsx"; -export { DataViewToast } from "./DataViewToast.tsx"; -export type { DataViewToastProps } from "./DataViewToast.tsx"; -export { UpdatedAt } from "./UpdatedAt.tsx"; -export type { UpdatedAtProps } from "./UpdatedAt.tsx"; -export { DateTimeTooltip } from "./DateTimeTooltip.tsx"; -export type { DateTimeTooltipProps } from "./DateTimeTooltip.tsx"; - -// Filters, URL state, and controls -export { LiveControl } from "./LiveControl.tsx"; -export type { LiveControlProps, LiveIntervalSeconds } from "./LiveControl.tsx"; -export { DateRangePicker } from "./DateRangePicker.tsx"; -export type { DatePresetId, DateRange, DateRangePickerProps } from "./DateRangePicker.tsx"; -export { - defineAppVariables, - textVariable, - selectVariable, - dateRangeVariable, - dateRangeControl, - useAppVariables, -} from "./variables.ts"; -export type { - AppVariable, - AppVariableValues, - DateRangeSelection, - DateRangeVariable, - DateRangeVariableOptions, -} from "./variables.ts"; -export { PeriodSummary } from "./PeriodSummary.tsx"; -export type { ReportingPeriod, PeriodComparison, PeriodSummaryProps } from "./PeriodSummary.tsx"; -export { SearchField } from "./SearchField.tsx"; -export type { SearchFieldProps } from "./SearchField.tsx"; -export { searchItems } from "./searchItems.ts"; -export type { - SearchAttribute, - SearchHit, - SearchItemsOptions, - SearchMatchValue, - SearchRange, -} from "./searchItems.ts"; -export { SearchMatch } from "./SearchMatch.tsx"; -export type { SearchMatchProps } from "./SearchMatch.tsx"; -export { Combobox } from "./Combobox.tsx"; -export type { ComboboxOption, ComboboxProps } from "./Combobox.tsx"; -export { searchParams, slug, subscribeSearch, writeSearch } from "./search.ts"; -export { Tabs, TabList, Tab, TabPanels, TabPanel, useViewTab } from "./Tabs.tsx"; - -// Data context and query inspection -export { createDataContext, defineDataContext, evidenceFor } from "./data-context.ts"; -export type { DataContext, GlossaryEntry } from "./data-context.ts"; -export { defineDataIdentifiers } from "./data-identifiers.tsx"; -export type { - DataIdentifierDefinition, - TableIdentifier, - ColumnIdentifier, -} from "./data-identifiers.tsx"; -export type { DisclosedQuery } from "../../core/contract.ts"; -export { GlossaryExplanation } from "./GlossaryExplanation.tsx"; -export type { GlossaryExplanationProps } from "./GlossaryExplanation.tsx"; -export { GlossaryDefinition } from "./GlossaryDefinition.tsx"; -export type { GlossaryDefinitionProps } from "./GlossaryDefinition.tsx"; -export { AboutData } from "./AboutData.tsx"; -export type { AboutDataProps, AboutEmpty, AboutSubject, AboutTab } from "./AboutData.tsx"; - -// Present mode -export { PlayStory } from "./PlayStory.tsx"; -export type { PlayStoryProps, StoryStep } from "./PlayStory.tsx"; - -// Buttons, overlays, and icons -export { AppIcon } from "./icons.ts"; -export type { AppIconName, AppIconProps } from "./icons.ts"; -export { IconButton } from "./IconButton.tsx"; -export type { IconButtonProps } from "./IconButton.tsx"; -export { Kbd } from "./Kbd.tsx"; -export type { KbdProps } from "./Kbd.tsx"; -export { Sheet } from "./Sheet.tsx"; -export type { SheetDialogProps, SheetProps } from "./Sheet.tsx"; -export { Tooltip, TooltipProvider } from "./Tooltip.tsx"; -export type { TooltipProps, TooltipProviderProps } from "./Tooltip.tsx"; -export { Button } from "./Button.tsx"; -export type { ButtonProps } from "./Button.tsx"; -export { HelpPopover } from "./HelpPopover.tsx"; -export type { - HelpPopoverPanelProps, - HelpPopoverProps, - HelpPopoverTriggerProps, -} from "./HelpPopover.tsx"; - -export type { MetricDefinition } from "./metric.ts"; -export type { DataReading, MetricReading, MetricValues } from "../../core/reading.ts"; diff --git a/data-app/runtime/src/react/ui/metric.ts b/data-app/runtime/src/react/ui/metric.ts deleted file mode 100644 index 07943c42..00000000 --- a/data-app/runtime/src/react/ui/metric.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { MetricFormat } from "../../core/format.ts"; -import type { WidgetEvidence } from "./WidgetEvidence.ts"; -import type { MetricReading } from "../../core/reading.ts"; -import { calendarMetricComparison } from "./comparison.ts"; - -export type MetricDefinition = { - id: string; - label: string; - format: MetricFormat; - favorableDirection?: "up" | "down"; - evidence: WidgetEvidence; -}; - -export function metricComparison(metric: MetricDefinition, reading: MetricReading) { - if (reading.loading || !reading.value.period) return undefined; - return calendarMetricComparison(reading.value.period, { - current: reading.value.current, - previous: reading.value.previous ?? null, - format: metric.format, - favorableDirection: metric.favorableDirection, - }); -} diff --git a/data-app/runtime/src/react/ui/search.ts b/data-app/runtime/src/react/ui/search.ts deleted file mode 100644 index 2b252a04..00000000 --- a/data-app/runtime/src/react/ui/search.ts +++ /dev/null @@ -1,42 +0,0 @@ -const SEARCH_CHANGE = "altertable:searchchange"; - -export function searchParams(): URLSearchParams { - return new URLSearchParams(typeof window === "undefined" ? "" : window.location.search); -} - -/** Listen to Back/Forward and writes made by another runtime control. */ -export function subscribeSearch(listener: () => void): () => void { - window.addEventListener("popstate", listener); - window.addEventListener(SEARCH_CHANGE, listener); - return () => { - window.removeEventListener("popstate", listener); - window.removeEventListener(SEARCH_CHANGE, listener); - }; -} - -/** Write search keys. `null` removes a key. Skips the history write when nothing changed. */ -export function writeSearch( - update: Record, - mode: "replace" | "push" = "replace", -): void { - const url = new URL(window.location.href); - for (const [key, value] of Object.entries(update)) { - if (value == null || value === "") url.searchParams.delete(key); - else url.searchParams.set(key, value); - } - const next = `${url.pathname}${url.search}${url.hash}`; - const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; - if (next === current) return; - window.history[mode === "push" ? "pushState" : "replaceState"](null, "", next); - window.dispatchEvent(new Event(SEARCH_CHANGE)); -} - -export function slug(value: string): string { - return ( - value - .normalize("NFKD") - .replace(/[^\w]+/g, "-") - .replace(/^-+|-+$/g, "") - .toLowerCase() || "item" - ); -} diff --git a/data-app/runtime/src/react/ui/searchItems.ts b/data-app/runtime/src/react/ui/searchItems.ts deleted file mode 100644 index 7ca45b6f..00000000 --- a/data-app/runtime/src/react/ui/searchItems.ts +++ /dev/null @@ -1,123 +0,0 @@ -import fuzzysort from "fuzzysort"; - -export type SearchRange = { start: number; end: number }; -/** The exact displayed text and its offsets stay together for safe rendering. */ -export type SearchMatchValue = { text: string; ranges: readonly SearchRange[] }; -export type SearchAttribute = { - name: string; - getter: (item: Item) => string; - weight?: number; -}; -export type SearchHit = { - item: Item; - score: number; - /** Render displayed fields with . */ - matches: Record; -}; -export type SearchItemsOptions = { - attributes: readonly [SearchAttribute, ...SearchAttribute[]]; - /** Literal filtering preserves source order; fuzzy search ranks discovery results. */ - mode?: "literal" | "fuzzy"; - limit?: number; - fuzzyThreshold?: number; -}; - -function mergeRanges(ranges: SearchRange[]): SearchRange[] { - const sorted = ranges.sort((a, b) => a.start - b.start || a.end - b.end); - const merged: SearchRange[] = []; - for (const range of sorted) { - const previous = merged.at(-1); - if (previous && range.start <= previous.end) previous.end = Math.max(previous.end, range.end); - else merged.push({ ...range }); - } - return merged; -} - -/** Fold accents without losing offsets into the original string. */ -function fold(text: string) { - let value = ""; - const offsets: SearchRange[] = []; - for (let start = 0; start < text.length;) { - const point = text.codePointAt(start)!; - const original = String.fromCodePoint(point); - const end = start + original.length; - const normalized = original - .normalize("NFD") - .replace(/\p{Diacritic}/gu, "") - .toLocaleLowerCase(); - for (let index = 0; index < normalized.length; index++) offsets.push({ start, end }); - value += normalized; - start = end; - } - return { value, offsets }; -} - -function literalRanges(text: string, terms: readonly string[]): SearchRange[][] { - const folded = fold(text); - return terms.map((term) => { - const ranges: SearchRange[] = []; - for ( - let index = folded.value.indexOf(term); - index !== -1; - index = folded.value.indexOf(term, index + term.length) - ) { - const first = folded.offsets[index]; - const last = folded.offsets[index + term.length - 1]; - if (first && last) ranges.push({ start: first.start, end: last.end }); - } - return ranges; - }); -} - -/** - * Search only a complete local collection. Empty queries retain every item. - * When a searched field is displayed, render its match with SearchMatch: - * `hits.map(hit => )`. - * Fields used only to find a row need no visible highlight. - */ -export function searchItems( - items: readonly Item[], - query: string, - options: SearchItemsOptions, -): SearchHit[] { - const normalizedQuery = fold(query.trim()).value; - const terms = normalizedQuery.split(/\s+/).filter(Boolean); - const mode = options.mode ?? "literal"; - const hits: SearchHit[] = []; - - for (const item of items) { - const matches: Record = {}; - let score = 0; - let matched = terms.length === 0; - const matchedTerms = new Set(); - - for (const attribute of options.attributes) { - const value = attribute.getter(item); - const fuzzy = - mode === "fuzzy" && normalizedQuery ? fuzzysort.single(query.trim(), value) : null; - if (fuzzy && fuzzy.score >= (options.fuzzyThreshold ?? 0.3)) { - matched = true; - score = Math.max(score, fuzzy.score * (attribute.weight ?? 1)); - matches[attribute.name] = { - text: value, - ranges: mergeRanges( - [...fuzzy.indexes].map((index) => ({ start: index, end: index + 1 })), - ), - }; - } else if (mode === "literal" && terms.length) { - const termMatches = literalRanges(value, terms); - termMatches.forEach((ranges, index) => { - if (ranges.length) matchedTerms.add(index); - }); - matches[attribute.name] = { text: value, ranges: mergeRanges(termMatches.flat()) }; - } else { - matches[attribute.name] = { text: value, ranges: [] }; - } - } - - if (mode === "literal" && terms.length) matched = matchedTerms.size === terms.length; - if (matched) hits.push({ item, score, matches }); - } - if (mode === "fuzzy" && terms.length) hits.sort((a, b) => b.score - a.score); - return options.limit === undefined ? hits : hits.slice(0, Math.max(0, options.limit)); -} diff --git a/data-app/runtime/src/react/ui/shortcuts.ts b/data-app/runtime/src/react/ui/shortcuts.ts deleted file mode 100644 index f217282b..00000000 --- a/data-app/runtime/src/react/ui/shortcuts.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { useEffect } from "react"; - -/** - * `alt` is Alt/Option, leaving character keys and browser refresh untouched; `mod` is ⌘ on Apple - * platforms and Ctrl elsewhere. - */ -export type Shortcut = { modifier: "alt" | "mod"; shift?: boolean; code: string; key: string }; - -export const shortcuts = { - refresh: { modifier: "alt", code: "KeyR", key: "R" }, - aboutData: { modifier: "alt", code: "KeyI", key: "I" }, - playStory: { modifier: "mod", code: "Enter", key: "Enter" }, -} as const satisfies Record; - -function isApple(): boolean { - return ( - typeof navigator !== "undefined" && /Macintosh|Mac OS X|iPhone|iPad/.test(navigator.userAgent) - ); -} - -export function shortcutLabel({ modifier, shift, key }: Shortcut): string { - const platform = isApple() ? "apple" : "other"; - const modifierLabel = { apple: { alt: "⌥", mod: "⌘" }, other: { alt: "Alt+", mod: "Ctrl+" } }[ - platform - ][modifier]; - const shiftLabel = shift ? { apple: "⇧", other: "Shift+" }[platform] : ""; - const keyLabel = - platform === "apple" ? (({ Enter: "↩" } as Record)[key] ?? key) : key; - return `${modifierLabel}${shiftLabel}${keyLabel}`; -} - -export function ariaKeyShortcuts({ modifier, shift, key }: Shortcut): string { - const modifiers = { alt: ["Alt"], mod: ["Meta", "Control"] }[modifier]; - return modifiers.map((name) => `${name}+${shift ? "Shift+" : ""}${key}`).join(" "); -} - -export function isEditingTarget(target: EventTarget | null): boolean { - return ( - target instanceof HTMLElement && - !!target.closest("input, textarea, select, [contenteditable], [role='textbox']") - ); -} - -/** Bind a page-level shortcut. Ignored while typing or while a modal layer is open. */ -export function useShortcut(shortcut: Shortcut, action: () => void, enabled = true): void { - const { modifier, shift = false, code } = shortcut; - useEffect(() => { - if (!enabled) return; - function onKeyDown(event: KeyboardEvent) { - if ( - event.defaultPrevented || - event.repeat || - event.isComposing || - event.shiftKey !== shift || - event.code !== code - ) - return; - const apple = isApple(); - const mod = apple ? event.metaKey : event.ctrlKey; - const pressed = { - alt: event.altKey && !event.ctrlKey && !event.metaKey, - mod: mod && !event.altKey && !(apple ? event.ctrlKey : event.metaKey), - }[modifier]; - if (!pressed || document.querySelector("dialog:modal") || isEditingTarget(event.target)) - return; - event.preventDefault(); - action(); - } - document.addEventListener("keydown", onKeyDown); - return () => document.removeEventListener("keydown", onKeyDown); - }, [modifier, shift, code, enabled, action]); -} diff --git a/data-app/runtime/src/react/ui/variables.ts b/data-app/runtime/src/react/ui/variables.ts deleted file mode 100644 index ff8dc8ce..00000000 --- a/data-app/runtime/src/react/ui/variables.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { useEffect, useState, useSyncExternalStore } from "react"; -import { today } from "@internationalized/date"; -import { subscribeSearch, writeSearch } from "./search.ts"; -import type { DatePresetId, DateRange } from "../../core/date-range.ts"; -import { - defineAppVariables, - type AppVariable, - type AppVariableValues, - type DateRangeSelection, - type DateRangeVariable, - type HistoryMode, - type VariableCollection, -} from "../../core/variables.ts"; -export { - defineAppVariables, - textVariable, - selectVariable, - dateRangeVariable, -} from "../../core/variables.ts"; -export type { - AppVariable, - AppVariableValues, - DateRangeSelection, - DateRangeVariable, - DateRangeVariableOptions, - VariableCollection, -} from "../../core/variables.ts"; - -/** Adapt one date variable to the controlled picker without giving the picker URL ownership. */ -export function dateRangeControl( - variable: DateRangeVariable, - selection: DateRangeSelection, - onChange: (selection: DateRangeSelection) => void, -) { - const value = variable.resolve(selection); - const update = (next: DateRangeSelection) => - onChange( - selection.comparison && variable.previous(next) ? { ...next, comparison: "previous" } : next, - ); - return { - value, - label: variable.label, - onChange: (range: DateRange | null) => - update(range ? { kind: "dates", ...range } : variable.defaultValue), - onPresetChange: (id: DatePresetId) => update({ kind: "preset", id }), - selectedPresetId: selection.kind === "preset" ? selection.id : null, - onReset: () => onChange(variable.defaultValue), - isDefault: variable.same(selection, variable.defaultValue), - resetValue: variable.resolve(variable.defaultValue), - ...(variable.supportsComparison - ? { - comparison: { - enabled: selection.comparison === "previous", - range: variable.previous(selection), - onChange: (enabled: boolean) => - onChange( - enabled - ? { ...selection, comparison: "previous" } - : { ...selection, comparison: undefined }, - ), - }, - } - : {}), - ...variable.bounds(), - }; -} - -const currentSearch = () => window.location.search; -const serverSearch = () => ""; - -/** URL is the source of truth for app variables; one update can change dependent values atomically. */ -export function useAppVariables( - definitions: Variables, -) { - defineAppVariables(definitions); - const search = useSyncExternalStore(subscribeSearch, currentSearch, serverSearch); - const zones = Object.values(definitions) - .filter((item) => item.kind === "dateRange") - .map((item) => (item as DateRangeVariable).bounds().timeZone); - const zoneKey = [...new Set(zones)].sort().join("|"); - const [, updateDay] = useState(0); - useEffect(() => { - if (!zoneKey) return; - const timeZones = zoneKey.split("|"); - let day = timeZones.map((zone) => today(zone).toString()).join("|"); - const timer = window.setInterval(() => { - const next = timeZones.map((zone) => today(zone).toString()).join("|"); - if (next !== day) { - day = next; - updateDay((value) => value + 1); - } - }, 30_000); - return () => window.clearInterval(timer); - }, [zoneKey]); - const params = new URLSearchParams(search); - const values = {} as AppVariableValues; - const canonical: Record = {}; - for (const name of Object.keys(definitions) as (keyof Variables & string)[]) { - const variable = definitions[name] as unknown as AppVariable< - AppVariableValues[typeof name] - >; - const value = variable.read(params); - values[name] = value; - Object.assign(canonical, variable.write(value)); - } - useEffect(() => { - if (Object.entries(canonical).some(([key, value]) => params.get(key) !== value)) - writeSearch(canonical); - }); - function update(next: Partial>, history?: HistoryMode) { - const changes: Record = {}; - let mode: HistoryMode = history ?? "replace"; - for (const name of Object.keys(next) as (keyof Variables & string)[]) { - const variable = definitions[name] as unknown as AppVariable< - AppVariableValues[typeof name] - >; - const value = next[name]!; - if (!variable.valid(value)) throw new Error(`Invalid value for variable ${name}.`); - Object.assign(changes, variable.write(value)); - if (!history && variable.history === "push") mode = "push"; - } - writeSearch(changes, mode); - } - function set( - name: Key, - value: AppVariableValues[Key], - ) { - update({ [name]: value } as Partial>); - } - function reset(name: Key) { - const variable = definitions[name] as unknown as AppVariable[Key]>; - set(name, variable.defaultValue); - } - function bind(name: Key) { - const variable = definitions[name] as unknown as AppVariable[Key]>; - return { - value: values[name], - onChange: (value: AppVariableValues[Key]) => set(name, value), - resetValue: variable.defaultValue, - }; - } - return { values, set, reset, update, bind }; -} diff --git a/data-app/runtime/src/react/view-controls.tsx b/data-app/runtime/src/react/view-controls.tsx deleted file mode 100644 index 88861736..00000000 --- a/data-app/runtime/src/react/view-controls.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { ReactNode } from "react"; -import { SearchField } from "./ui/SearchField.tsx"; -import { Combobox } from "./ui/Combobox.tsx"; -import { DateRangePicker } from "./ui/DateRangePicker.tsx"; -import { - dateRangeControl, - useAppVariables, - type AppVariableValues, - type VariableCollection, - type DateRangeSelection, -} from "./ui/variables.ts"; -import type { ResolvedVariables } from "./view.ts"; - -/** Resolves URL selections once per render. Date controls and operation inputs share that selection. */ -export function useViewVariables(definitions: Variables) { - const variables = useAppVariables(definitions); - const resolved = {} as ResolvedVariables; - const controls: ReactNode[] = []; - for (const name of Object.keys(definitions) as (keyof Variables & string)[]) { - const definition = definitions[name]!; - const value = variables.values[name]; - if (definition.kind === "dateRange") { - const selection = value as DateRangeSelection; - resolved[name] = definition.input(selection) as ResolvedVariables[typeof name]; - controls.push( - - variables.set(name, next as AppVariableValues[typeof name]), - )} - />, - ); - } else { - resolved[name] = value as ResolvedVariables[typeof name]; - const binding = { - label: definition.label ?? name, - value: value as string, - onChange: (next: string) => - variables.set(name, next as AppVariableValues[typeof name]), - resetValue: definition.defaultValue, - }; - if (definition.kind === "select" && definition.options) - controls.push(); - else if (definition.kind === "text") controls.push(); - } - } - return { ...variables, resolved, controls: controls.length ? controls : null }; -} diff --git a/data-app/runtime/src/react/view.ts b/data-app/runtime/src/react/view.ts deleted file mode 100644 index b0a1ed22..00000000 --- a/data-app/runtime/src/react/view.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { EmptyStateProps } from "./ui/EmptyState.tsx"; -import type { - AppVariableValues, - DateRangeVariable, - VariableCollection, -} from "../core/variables.ts"; -import type { DateRangeRequest } from "../core/contract.ts"; - -export type ResolvedVariables = { - [Key in keyof Variables]: Variables[Key] extends DateRangeVariable - ? DateRangeRequest - : AppVariableValues[Key]; -}; - -type DateVariableKey = { - [Key in keyof Variables]: Variables[Key] extends DateRangeVariable ? Key : never; -}[keyof Variables] & - string; - -export type ViewDate = { - variable: DateVariableKey; - input: (input: Input) => DateRangeRequest; -}; - -export type DataViewDefinition< - Name extends string, - Variables extends VariableCollection, - Input, - Data, -> = { - operation: Name; - variables: Variables; - input: (values: ResolvedVariables) => Input; - isEmpty: (data: Data) => boolean; - empty: Pick; -} & ( - | { date: ViewDate; describeInput?: (input: Input) => string } - | { date?: never; describeInput: (input: Input) => string } -); - -export function describeViewInput(definition: { - variables: VariableCollection; - date?: { variable: string; input: (input: Input) => DateRangeRequest }; - describeInput?: (input: Input) => string; -}): (input: Input) => string { - const date = definition.date; - const variable = date && definition.variables[date.variable]; - if (date && variable?.kind !== "dateRange") - throw new Error("The view date must reference a date range variable."); - if (definition.describeInput) return definition.describeInput; - if (!date || !variable) throw new Error("A view needs a date binding or describeInput."); - return (input) => (variable as DateRangeVariable).describeInput(date.input(input)); -} - -export function resolveViewInput( - definition: { - input: (values: ResolvedVariables) => Input; - date?: ViewDate; - }, - values: ResolvedVariables, -): Input { - const input = definition.input(values); - if (definition.date) { - const selected = values[definition.date.variable] as DateRangeRequest; - const mapped = definition.date.input(input); - const sameRange = (a: DateRangeRequest["comparison"], b: DateRangeRequest["comparison"]) => - a === null || b === null ? a === b : a.start === b.start && a.end === b.end; - if ( - !sameRange(selected.range, mapped.range) || - !sameRange(selected.comparison, mapped.comparison) - ) - throw new Error("The operation input must preserve the selected date range and comparison."); - } - return input; -} diff --git a/data-app/runtime/src/server/handler.ts b/data-app/runtime/src/server/handler.ts deleted file mode 100644 index e8fe306d..00000000 --- a/data-app/runtime/src/server/handler.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { - DataSourceError, - type DataOperations, - type DisclosedQuery, - type Lakehouse, -} from "../core/contract.ts"; - -/** Authorization result for one viewer request. A hosted adapter must scope its lakehouse client. */ -export type RequestAccess = { lakehouse: Lakehouse; canDiscloseSql: boolean }; - -/** - * Execute named operations on the server. The app supplies operations and viewer authorization. - * JSON, Origin, and Fetch Metadata checks reject cross-site browser POSTs; they do not - * authenticate another local process. SQL is returned only when both the operation and - * authorization result permit disclosure. - */ -export function createDataHandler( - operations: DataOperations, - authorize: (request: Request, operation: string) => Promise, -) { - return async (request: Request): Promise => { - const requestId = crypto.randomUUID(); - const url = new URL(request.url); - if (!url.pathname.startsWith("/api/data/")) return new Response("Not found", { status: 404 }); - const encodedName = url.pathname.slice("/api/data/".length); - if (!encodedName || encodedName.includes("/")) - return problem(404, "not_found", "Unknown data operation.", requestId); - let name: string; - try { - name = decodeURIComponent(encodedName); - } catch { - return problem(404, "not_found", "Unknown data operation.", requestId); - } - if (!Object.hasOwn(operations, name)) - return problem(404, "not_found", "Unknown data operation.", requestId); - if (request.method !== "POST") - return problem(405, "method_not_allowed", "Use POST.", requestId); - if ( - request.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() !== - "application/json" - ) - return problem(415, "unsupported_media_type", "Use application/json.", requestId); - const origin = request.headers.get("origin"); - const fetchSite = request.headers.get("sec-fetch-site"); - const fetchMode = request.headers.get("sec-fetch-mode"); - if ( - (origin !== null && origin !== url.origin) || - (fetchSite !== null && fetchSite !== "same-origin") || - (fetchSite !== null && origin === null) || - (fetchMode !== null && fetchMode !== "cors" && fetchMode !== "same-origin") - ) - return problem( - 403, - "cross_origin_request", - "The data request must come from this app.", - requestId, - ); - let access: RequestAccess; - try { - access = await authorize(request, name); - } catch { - return problem(403, "forbidden", "You cannot run this data operation.", requestId); - } - const operation = operations[name]!; - let input: unknown; - try { - const body = await request.text(); - if (body.length > 16_384) - return problem(413, "input_too_large", "Input is too large.", requestId); - input = operation.input(JSON.parse(body)); - } catch { - return problem(400, "invalid_input", "Check this operation's inputs.", requestId); - } - const timeout = AbortSignal.timeout(operation.policy.maxDurationMs); - const signal = AbortSignal.any([request.signal, timeout]); - const queryIds: string[] = []; - const queries: DisclosedQuery[] = []; - const boundedLakehouse: Lakehouse = { - async queryAll(statement, options) { - if (!Number.isInteger(options.limit) || options.limit < 1) - throw new Error("Query needs a positive row limit."); - if ( - operation.queryNames && - !Object.values(operation.queryNames).includes(options.name ?? "") - ) - throw new Error(`Query name is not registered for operation ${name}.`); - const query: DisclosedQuery = { - name: options.name ?? `Query ${queries.length + 1}`, - statement, - }; - queries.push(query); - let result; - try { - result = await access.lakehouse.queryAll(statement, { - limit: Math.min(options.limit, operation.policy.maxQueryRows), - signal: AbortSignal.any([signal, options.signal]), - }); - } catch (error) { - if (error instanceof DataSourceError) error.queryName = query.name; - throw error; - } - if (result.rows.length > operation.policy.maxQueryRows) - throw new Error("Result exceeds row limit."); - if (result.queryId) { - query.queryId = result.queryId; - queryIds.push(result.queryId); - } - return result; - }, - }; - try { - const data = operation.output( - await Promise.race([ - operation.run({ lakehouse: boundedLakehouse, signal }, input as never), - new Promise((_, reject) => - signal.addEventListener("abort", () => reject(new Error("Request aborted.")), { - once: true, - }), - ), - ]), - ); - const result = { - data, - requestId, - queriedAt: new Date().toISOString(), - queryIds, - ...(operation.policy.exposeSql && access.canDiscloseSql ? { queries } : {}), - }; - const body = JSON.stringify(result); - if ( - new TextEncoder().encode(body).byteLength > (operation.policy.maxResponseBytes ?? 1_000_000) - ) { - throw new Error("Response exceeds byte limit."); - } - return new Response(body, { - headers: { "cache-control": "no-store", "content-type": "application/json" }, - }); - } catch (error) { - const source = error instanceof DataSourceError ? error : null; - console.error( - `Data operation ${name} failed (${requestId}): ${source ? `${source.reason}${source.status ? ` (${source.status})` : ""}${source.queryName ? ` in ${source.queryName}` : ""}` : error instanceof Error ? error.name : "unknown"}`, - ); - if (source) { - const messages = { - unauthorized: - "Lakehouse access needs attention. Run this app with 'altertable app dev', or run 'altertable login' and retry.", - forbidden: - "This app cannot access its lakehouse data. Check the selected profile's permissions.", - rate_limited: "The lakehouse is busy. Wait a moment and retry.", - query_rejected: "A lakehouse query was rejected. Check the app's data operation.", - unavailable: "The lakehouse is unavailable. Check the connection and retry.", - }; - return problem( - source.reason === "rate_limited" ? 429 : 502, - `source_${source.reason}`, - messages[source.reason], - requestId, - ); - } - return problem( - signal.aborted ? 504 : 502, - signal.aborted ? "timeout" : "query_failed", - signal.aborted ? "The data request timed out." : "The data request failed.", - requestId, - ); - } - }; -} - -function problem(status: number, code: string, message: string, requestId: string): Response { - return Response.json( - { error: { code, message, requestId } }, - { status, headers: { "cache-control": "no-store" } }, - ); -} diff --git a/data-app/runtime/src/server/index.ts b/data-app/runtime/src/server/index.ts deleted file mode 100644 index bd116c57..00000000 --- a/data-app/runtime/src/server/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { createDataHandler } from "./handler.ts"; -export type { RequestAccess } from "./handler.ts"; -export { serveLocalApp, localLakehouse } from "./local.ts"; diff --git a/data-app/runtime/src/server/local.ts b/data-app/runtime/src/server/local.ts deleted file mode 100644 index 9536c506..00000000 --- a/data-app/runtime/src/server/local.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { DataSourceError, type Lakehouse, type QueryResult } from "../core/contract.ts"; -import type { DataOperations } from "../core/contract.ts"; -import { createDataHandler } from "./handler.ts"; - -/** Serve one app on localhost through the CLI's lakehouse proxy during development. - * Hosted servers must authorize every viewer and must use `createDataHandler` directly. */ -export function serveLocalApp({ - page, - operations, - title, - port = Number(process.env.PORT ?? 25837), -}: { - page: Bun.HTMLBundle; - operations: DataOperations; - title: string; - port?: number; -}) { - const server = Bun.serve({ - hostname: "127.0.0.1", - port, - development: process.env.NODE_ENV !== "production", - idleTimeout: 60, - routes: { "/": page }, - fetch: createDataHandler(operations, async () => ({ - lakehouse: localLakehouse(), - canDiscloseSql: true, - })), - }); - console.log(`${title} running at ${server.url}`); - return server; -} - -/** Server-only lakehouse adapter. Local development uses the selected CLI profile's proxy. */ -export function localLakehouse( - environment: Record = process.env, - request: typeof fetch = fetch, -): Lakehouse { - return { - async queryAll(statement, { limit, signal }) { - const proxyUrl = environment.ALTERTABLE_DATA_PROXY_URL; - const proxyToken = environment.ALTERTABLE_DATA_PROXY_TOKEN; - const username = environment.ALTERTABLE_LAKEHOUSE_USERNAME; - const password = environment.ALTERTABLE_LAKEHOUSE_PASSWORD; - if (!proxyUrl && (!username || !password)) throw new DataSourceError("unauthorized"); - if (proxyUrl && !proxyToken) throw new DataSourceError("unauthorized"); - const base = proxyUrl ?? environment.ALTERTABLE_API_BASE ?? "https://api.altertable.ai"; - let response: Response; - try { - response = await request(new URL("query", `${base.replace(/\/$/, "")}/`), { - method: "POST", - headers: { - authorization: proxyUrl - ? `Bearer ${proxyToken}` - : `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`, - "content-type": "application/json", - }, - body: JSON.stringify({ statement, limit }), - signal, - }); - } catch (error) { - if (signal.aborted) throw error; - throw new DataSourceError("unavailable"); - } - if (!response.ok) { - const reason = - response.status === 401 - ? "unauthorized" - : response.status === 403 - ? "forbidden" - : response.status === 429 - ? "rate_limited" - : response.status === 400 || response.status === 422 - ? "query_rejected" - : "unavailable"; - throw new DataSourceError(reason, response.status); - } - const reader = response.body?.getReader(); - if (!reader) throw new Error("Query response is empty."); - const decoder = new TextDecoder(); - let body = ""; - let bytes = 0; - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - bytes += chunk.value.byteLength; - if (bytes > 5_000_000) { - await reader.cancel(); - throw new Error("Query response is too large."); - } - body += decoder.decode(chunk.value, { stream: true }); - } - body += decoder.decode(); - const lines = body - .trimEnd() - .split("\n") - .filter((line) => line.trim()); - if (!lines.length) throw new Error("Query response is empty."); - const metadata = JSON.parse(lines[0]!) as { query_id?: string }; - let next = 1; - let columns: QueryResult["columns"] = []; - if (lines[next]) { - const second = JSON.parse(lines[next]!) as unknown; - if ( - Array.isArray(second) && - second.every( - (value) => - typeof value === "string" || - (typeof value === "object" && value !== null && "name" in value), - ) - ) { - columns = second.map((value) => - typeof value === "string" - ? { name: value } - : (value as { name: string; type?: string }), - ); - next++; - } - } - const rows = lines.slice(next).map((line) => JSON.parse(line) as unknown[]); - if (rows.length > limit) throw new Error("Lakehouse returned more rows than requested."); - return { columns, rows, queryId: metadata.query_id }; - }, - }; -} diff --git a/data-app/runtime/tests/authoring.test.ts b/data-app/runtime/tests/authoring.test.ts deleted file mode 100644 index 32ba5ccc..00000000 --- a/data-app/runtime/tests/authoring.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { expect, test } from "bun:test"; -import { - connectionCheck, - defineDateRangeContract, - parseCount, - parseDateRangeInput, - parseEmptyInput, - parseLabel, - parseTrue, - previousDateRange, - rowsAsRecords, -} from "../src/core/contract.ts"; -import { dataAppTitle } from "../src/core/config.ts"; -import { resolveDataView } from "../src/core/data-view.ts"; - -test("starter connection requires a successful bounded query", async () => { - const query = connectionCheck(); - const calls: unknown[] = []; - const result = await query.run( - { - signal: new AbortController().signal, - lakehouse: { - async queryAll(statement, options) { - calls.push({ statement, limit: options.limit, name: options.name }); - return { columns: [{ name: "connection_check" }], rows: [[1]] }; - }, - }, - }, - query.input({}), - ); - expect(result).toBe(true); - expect(calls).toEqual([ - { statement: "SELECT 1 AS connection_check", limit: 1, name: "connection-check" }, - ]); - expect(query.output(true)).toBe(true); - expect(() => parseTrue(false)).toThrow(); - expect(() => parseEmptyInput({ extra: true })).toThrow(); - expect(() => parseEmptyInput([])).toThrow(); - expect( - query.run( - { - signal: new AbortController().signal, - lakehouse: { - async queryAll() { - throw new Error("No lakehouse access"); - }, - }, - }, - {}, - ), - ).rejects.toThrow("No lakehouse access"); -}); - -test("server parsers reject invalid dates, counts, and query shapes", () => { - const bounded = { minDate: "2026-01-01", maxDate: "2026-01-31", maxRangeDays: 7 }; - expect(parseDateRangeInput({ start: "2026-01-02", end: "2026-01-08" }, bounded)).toEqual({ - start: "2026-01-02", - end: "2026-01-08", - }); - for (const range of [ - { start: "2026-02-30", end: "2026-02-30" }, - { start: "2026-01-08", end: "2026-01-02" }, - { start: "2026-01-01", end: "2026-01-08" }, - { start: "2025-12-31", end: "2026-01-02" }, - ]) { - expect(() => parseDateRangeInput(range, bounded)).toThrow(); - } - expect(parseCount("12")).toBe(12); - for (const value of [-1, 1.5, "not-a-number", Number.MAX_SAFE_INTEGER + 1]) { - expect(() => parseCount(value)).toThrow(); - } - expect(parseLabel("Catalog")).toBe("Catalog"); - expect(() => parseLabel("", 20)).toThrow(); - expect( - rowsAsRecords({ columns: [{ name: "name" }, { name: "count" }], rows: [["A", 2]] }, ["count"]), - ).toEqual([{ name: "A", count: 2 }]); - expect(() => rowsAsRecords({ columns: [{ name: "name" }], rows: [["A"]] }, ["count"])).toThrow(); - expect(() => - rowsAsRecords({ columns: [{ name: "name" }], rows: [["A", 2]] }, ["name"]), - ).toThrow(); -}); - -test("document title reflects the app's configured scope", () => { - expect( - dataAppTitle({ title: "Revenue", scope: { organization: "Acme", environment: "production" } }), - ).toBe("Revenue • Acme/production • Altertable app"); -}); - -test("request state labels data from an older input and preserves it on failure", () => { - const options = { - requestedInput: { start: "2026-02-01" }, - previous: { input: { start: "2026-01-01" }, data: { count: 12 } }, - pending: true, - sameInput: (left: { start: string }, right: { start: string }) => left.start === right.start, - describe: (input: { start: string }) => input.start, - isEmpty: () => false, - }; - expect(resolveDataView(options)).toMatchObject({ - kind: "updating", - displayedInput: { start: "2026-01-01" }, - requestedInput: { start: "2026-02-01" }, - }); - expect( - resolveDataView({ ...options, pending: false, error: new Error("unavailable") }), - ).toMatchObject({ - kind: "stale-error", - data: { count: 12 }, - }); -}); - -test("a changing date range keeps the displayed period with its result", () => { - const range = defineDateRangeContract({ - minDate: "2026-01-01", - maxDate: "2026-01-31", - maxRangeDays: 7, - timeZone: "UTC", - }); - const displayed = { start: "2026-01-01", end: "2026-01-03" }; - const requested = { start: "2026-01-04", end: "2026-01-06" }; - const view = resolveDataView({ - requestedInput: requested, - previous: { input: displayed, data: { count: 4 } }, - pending: true, - sameInput: (left, right) => left.start === right.start && left.end === right.end, - describe: range.describeInput, - isEmpty: () => false, - }); - expect(view.kind).toBe("updating"); - if (view.kind === "updating") { - expect(range.period(view.displayedInput)).toEqual({ - ...displayed, - kind: "calendar", - timeZone: "UTC", - }); - expect(view.message).toContain(range.describeInput(requested)); - } - expect( - resolveDataView({ - requestedInput: requested, - current: { input: requested, data: { count: 0 } }, - pending: false, - sameInput: (left, right) => left.start === right.start && left.end === right.end, - describe: range.describeInput, - isEmpty: (data) => data.count === 0, - }), - ).toEqual({ kind: "empty", input: requested }); -}); - -test("comparison uses the preceding equal-length range and respects source coverage", () => { - expect(previousDateRange({ start: "2026-03-01", end: "2026-03-03" })).toEqual({ - start: "2026-02-26", - end: "2026-02-28", - }); - const contract = defineDateRangeContract({ - minDate: "2026-02-27", - maxDate: "2026-03-31", - maxRangeDays: 31, - timeZone: "UTC", - }); - expect(contract.comparison({ start: "2026-03-04", end: "2026-03-06" })).toEqual({ - start: "2026-03-01", - end: "2026-03-03", - }); - expect(contract.comparison({ start: "2026-03-01", end: "2026-03-03" })).toBeNull(); - expect(contract.describeInput({ start: "2026-03-04", end: "2026-03-06" })).toBe( - "Mar 4–6, 2026 UTC", - ); -}); diff --git a/data-app/runtime/tests/bindings.test.tsx b/data-app/runtime/tests/bindings.test.tsx deleted file mode 100644 index 2f5a65d8..00000000 --- a/data-app/runtime/tests/bindings.test.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import { expect, test } from "bun:test"; -import { renderToStaticMarkup } from "react-dom/server"; -import { - defineDateRangeContract, - defineOperation, - defineQueryNames, -} from "../src/core/contract.ts"; -import { createDataClient } from "../src/client/index.ts"; -import { createDataHooks } from "../src/react/index.ts"; -import { resolveViewInput } from "../src/react/view.ts"; -import { dateRangeVariable } from "../src/react/ui/variables.ts"; -import { createDataContext } from "../src/react/ui/data-context.ts"; -import { ComparisonVisual } from "../src/react/ui/ComparisonVisual.tsx"; -import { VisualizationWidget } from "../src/react/ui/VisualizationWidget.tsx"; -import { TableWidget } from "../src/react/ui/TableWidget.tsx"; -import { WidgetViewTabs } from "../src/react/ui/WidgetViewTabs.tsx"; -import type { DataOperation, DateRangeRequest } from "../src/core/contract.ts"; - -const names = defineQueryNames({ activity: "activity" }); -const context = createDataContext(names)({ - description: "Activity", - glossary: { - actions: { term: "Actions", definition: "Recorded actions", queryNames: [names.activity] }, - }, -}); -const actions = context.metric({ id: "actions", glossaryId: "actions", format: { kind: "count" } }); -const featureEvidence = context.evidence({ id: "features", queryNames: [names.activity] }); -const calendar = defineDateRangeContract({ - timeZone: "UTC", - maxRangeDays: 31, - minDate: "2026-01-01", - maxDate: "2026-03-31", -}); -const period = dateRangeVariable({ - key: "period", - contract: calendar, - comparison: true, - defaultValue: { kind: "preset", id: "last-7" }, -}); -type Data = { current: number; previous: number | null; rows: string[] }; -const { defineDataView } = createDataHooks<{ activity: DataOperation }>( - createDataClient(), -); -const view = defineDataView({ - operation: "activity", - variables: { period }, - input: ({ period }) => period, - date: { variable: "period", input: (input) => input }, - isEmpty: (data) => data.rows.length === 0, - empty: { title: "No actions" }, -}); - -test("bound metrics share values, formatting, evidence and displayed comparison periods", () => { - expect(actions.evidence).toEqual({ - id: "actions", - glossaryIds: ["actions"], - queryNames: ["activity"], - }); - const content = view.content((result) => { - const reading = result.metric((data) => ({ current: data.current, previous: data.previous })); - return ; - }); - const input = calendar.request({ start: "2026-03-10", end: "2026-03-12" }, true); - const html = renderToStaticMarkup( - content.children({ current: 120, previous: 100, rows: ["a"] }, input), - ); - expect(html).toContain("120"); - expect(html).toContain("20.0%"); - expect(html).toContain("Mar 10–12, 2026"); - expect(html).toContain("Mar 7–9, 2026"); - expect( - renderToStaticMarkup(content.children({ current: 0, previous: null, rows: [] }, input)), - ).toContain("No comparable previous value"); - expect( - renderToStaticMarkup(content.children({ current: 120, previous: 0, rows: [] }, input)), - ).not.toContain("Infinity"); -}); - -test("favorable direction colors a comparison without changing its numeric direction", () => { - const fewerIsBetter = context.metric({ - id: "errors", - glossaryId: "actions", - format: { kind: "count" }, - favorableDirection: "down", - }); - const content = view.content((result) => ( - ({ current: data.current, previous: data.previous }))} - /> - )); - const input = calendar.request({ start: "2026-03-10", end: "2026-03-12" }, true); - const html = renderToStaticMarkup( - content.children({ current: 120, previous: 100, rows: ["a"] }, input), - ); - expect(html).toContain('data-tone="bad"'); - expect(html).toContain("20.0%"); -}); - -test("bound visual selectors do not run during loading or render an empty result", () => { - let calls = 0; - const content = view.content((result) => ( - { - calls++; - return data.rows; - })} - isEmpty={(rows) => rows.length === 0} - empty={{ title: "No features" }} - skeleton={{ variant: "ranking", rows: 6 }} - > - {(rows) =>

{rows.join(", ")}

} -
- )); - expect(calls).toBe(0); - expect( - renderToStaticMarkup(content.loading).match(/class="altertable-content-skeleton-row"/g), - ).toHaveLength(6); - const input = calendar.request({ start: "2026-03-10", end: "2026-03-12" }); - expect( - renderToStaticMarkup(content.children({ current: 0, previous: null, rows: [] }, input)), - ).toContain("No features"); - expect(calls).toBe(1); -}); - -test("date bindings reject silently changed ranges and comparisons", () => { - const selection = calendar.request({ start: "2026-03-10", end: "2026-03-12" }, true); - expect(resolveViewInput(view, { period: selection })).toEqual(selection); - expect(() => - resolveViewInput( - { ...view, input: ({ period }) => ({ ...period, comparison: null }) }, - { period: selection }, - ), - ).toThrow("preserve"); -}); - -test("operation query runner supplies registered identity, policy limit and cancellation", async () => { - const operation = defineOperation({ - input: () => ({}), - output: () => true, - checks: [{}], - queryNames: names, - policy: { maxQueryRows: 12, maxDurationMs: 1000 }, - async run({ query }) { - await query(names.activity, "SELECT 1"); - return true; - }, - }); - const signal = new AbortController().signal; - let captured: unknown; - await operation.run( - { - signal, - lakehouse: { - async queryAll(statement, options) { - captured = { statement, ...options }; - return { columns: [], rows: [] }; - }, - }, - }, - {}, - ); - expect(captured).toEqual({ statement: "SELECT 1", name: "activity", limit: 12, signal }); -}); - -test("widget tabs reject duplicate and unknown IDs instead of producing a blank panel", () => { - const tab = { - id: "actions", - label: "Actions", - content: "Ready", - isEmpty: false, - empty: { title: "Empty" }, - }; - expect(() => - renderToStaticMarkup( - {}} - />, - ), - ).toThrow("unique"); - expect(() => - renderToStaticMarkup( - {}} - />, - ), - ).toThrow("Unknown widget tab"); -}); - -test("bound visualization views render inside one widget with a selected view", () => { - const html = renderToStaticMarkup( - rows.length === 0} - empty={{ title: "No feature use" }} - viewLabel="Measure" - views={[ - { - id: "actions", - label: "Actions", - render: (rows) => {rows[0]?.count} actions, - }, - { id: "reach", label: "Reach", render: (rows) => {rows[0]?.name} }, - ]} - />, - ); - expect(html).toContain("Feature use"); - expect(html).toContain("Actions"); - expect(html).toContain("Reach"); - expect(html).toContain("4 actions"); -}); - -test("bound tables keep their row contract while loading", () => { - const content = view.content((result) => ( - data.rows)} - rowKey={(row) => row} - columns={[{ id: "feature", header: "Feature", cell: (row) => row }]} - empty={{ title: "No features" }} - skeletonRows={3} - /> - )); - expect( - renderToStaticMarkup(content.loading).match(/class="altertable-content-skeleton-row"/g), - ).toHaveLength(3); - const input = calendar.request({ start: "2026-03-10", end: "2026-03-12" }); - expect( - renderToStaticMarkup( - content.children({ current: 1, previous: null, rows: ["Insights"] }, input), - ), - ).toContain("Insights"); - expect( - renderToStaticMarkup(content.children({ current: 0, previous: null, rows: [] }, input)), - ).toContain("No features"); -}); diff --git a/data-app/runtime/tests/boundaries.test.ts b/data-app/runtime/tests/boundaries.test.ts deleted file mode 100644 index 8ad79c3c..00000000 --- a/data-app/runtime/tests/boundaries.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { expect, test } from "bun:test"; -import { readFile } from "node:fs/promises"; -import { dirname, resolve, sep } from "node:path"; - -const source = resolve(import.meta.dir, "../src"); -const layers = { - core: new Set(["core"]), - client: new Set(["core", "client"]), - server: new Set(["core", "server"]), -} as const; - -test("non-React layers import only their allowed source layers", async () => { - for (const [layer, allowed] of Object.entries(layers)) { - const root = resolve(source, layer); - for await (const name of new Bun.Glob("**/*.{ts,tsx}").scan(root)) { - const path = resolve(root, name); - const contents = await readFile(path, "utf8"); - const imports = contents.matchAll(/\b(?:from|import)\s*(?:\(\s*)?["']([^"']+)["']/g); - for (const [, specifier] of imports) { - if (!specifier) continue; - if (!specifier.startsWith(".")) { - expect(specifier, `${name} must not import React or its UI dependencies`).not.toMatch( - /^(?:react(?:-dom|-aria-components)?|@tanstack\/react-query|@floating-ui\/react|lucide-react)\b/, - ); - continue; - } - const target = resolve(dirname(path), specifier); - expect(target.startsWith(`${source}${sep}`), `${name} imports outside src`).toBe(true); - const targetLayer = target.slice(source.length + 1).split(sep)[0]; - expect(allowed.has(targetLayer), `${name} imports ${specifier} from ${targetLayer}`).toBe( - true, - ); - } - expect(contents, `${name} must not import CSS`).not.toMatch(/\bimport\s*["'][^"']+\.css["']/); - } - } -}); - -test("React implementation modules do not import the public entry", async () => { - const root = resolve(source, "react"); - const entry = resolve(root, "index.ts"); - for await (const name of new Bun.Glob("**/*.{ts,tsx}").scan(root)) { - const path = resolve(root, name); - if (path === entry) continue; - const contents = await readFile(path, "utf8"); - const imports = contents.matchAll(/\b(?:from|import)\s*(?:\(\s*)?["']([^"']+)["']/g); - for (const [, specifier] of imports) { - if (!specifier?.startsWith(".")) continue; - expect(resolve(dirname(path), specifier), `${name} imports the public entry`).not.toBe(entry); - } - } -}); diff --git a/data-app/runtime/tests/components.test.tsx b/data-app/runtime/tests/components.test.tsx deleted file mode 100644 index 2fc19c64..00000000 --- a/data-app/runtime/tests/components.test.tsx +++ /dev/null @@ -1,294 +0,0 @@ -import { expect, test } from "bun:test"; -import { createElement } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { searchItems } from "../src/react/ui/searchItems.ts"; -import { SearchMatch } from "../src/react/ui/SearchMatch.tsx"; -import { ariaKeyShortcuts, shortcutLabel } from "../src/react/ui/shortcuts.ts"; -import { TableWidget } from "../src/react/ui/TableWidget.tsx"; -import { QueryList, formatSql } from "../src/react/ui/QueryList.tsx"; -import { DataSection } from "../src/react/ui/DataSection.tsx"; -import { - dateRangeControl, - dateRangeVariable, - defineAppVariables, - selectVariable, - textVariable, -} from "../src/react/ui/variables.ts"; -import { createDataClient, DataAppError } from "../src/client/index.ts"; -import { createDataHandler } from "../src/server/index.ts"; -import { defineDateRangeContract, defineQueryNames } from "../src/core/contract.ts"; -import { chartColor } from "../src/react/ui/chartColor.ts"; -import { formatMetric } from "../src/core/format.ts"; - -test("initial data errors show a useful recovery action for each failure", () => { - function render(code: string) { - return renderToStaticMarkup( - {}, - }} - > - {() => null} - , - ); - } - const unavailable = render("source_unavailable"); - expect(unavailable).toContain("Couldn’t load results"); - expect(unavailable).toContain("The lakehouse isn’t responding."); - expect(unavailable).toContain("Retry"); - expect(unavailable).not.toContain("Raw server text"); - const forbidden = render("source_forbidden"); - expect(forbidden).toContain("Data access denied"); - expect(forbidden).not.toContain("Retry"); - const query = render("source_query_rejected"); - expect(query).toContain("Ask the app owner to check the data operation."); - expect(query).not.toContain("Retry"); -}); - -test("local search preserves table order and highlights original text", () => { - const rows = [ - { id: "first", name: "Café
- -
- {column.header} -
- {column.cell(hit.item, hit)} - - {column.cell(hit.item, hit)} -
", catalog: "prod" }, - { id: "second", name: "Cafe", catalog: "stage" }, - ]; - const attributes = [ - { name: "name", getter: (row: (typeof rows)[number]) => row.name }, - { name: "catalog", getter: (row: (typeof rows)[number]) => row.catalog }, - ] as const; - const hits = searchItems(rows, "cafe prod", { attributes }); - expect(hits).toEqual([ - { - item: rows[0], - score: 0, - matches: { - name: { text: rows[0]!.name, ranges: [{ start: 0, end: 4 }] }, - catalog: { text: "prod", ranges: [{ start: 0, end: 4 }] }, - }, - }, - ]); - expect(searchItems(rows, "", { attributes }).map((hit) => hit.item.id)).toEqual([ - "first", - "second", - ]); - expect(searchItems(rows, "cafe", { attributes, mode: "fuzzy" })).toHaveLength(2); - expect( - renderToStaticMarkup( - createElement(SearchMatch, { - match: hits[0]!.matches.name, - }), - ), - ).toContain("Café <table>"); - const table = renderToStaticMarkup( - createElement(TableWidget<(typeof hits)[number]>, { - title: "Results", - count: hits.length, - columns: [ - { - id: "name", - header: "Name", - cell: (hit: (typeof hits)[number]) => - createElement(SearchMatch, { match: hit.matches.name }), - }, - { id: "count", header: "Count", type: "number", cell: () => "12" }, - ], - rows: hits, - rowKey: (hit: (typeof hits)[number]) => hit.item.id, - empty: { title: "No matching rows" }, - }), - ); - expect(table).toContain("Café <table>"); - expect(table).toContain('class="altertable-data-panel-count">1'); - expect(table).toMatch(/]*data-type="number"[^>]*>Count<\/th>/); - expect(table).toMatch(/]*data-type="number"[^>]*>12<\/td>/); -}); - -test("table search finds a later matching row before applying the display limit", () => { - const rows = [ - { id: "first", name: "Alpha" }, - { id: "last", name: "Café" }, - ]; - const table = renderToStaticMarkup( - createElement(TableWidget<(typeof rows)[number]>, { - title: "Customers", - columns: [{ id: "name", header: "Name", cell: (row) => row.name }], - rows, - rowKey: (row) => row.id, - limit: 1, - search: { - value: "cafe", - onChange: () => {}, - label: "Search customers", - attributes: [{ name: "name", getter: (row) => row.name }], - }, - empty: { title: "No matching customers" }, - }), - ); - expect(table).toContain("Café"); - expect(table).not.toContain("Alpha"); -}); - -test("query notebook groups disclosed SQL and exposes one copy-all action", () => { - const html = renderToStaticMarkup( - , - ); - expect(html).toContain('aria-label="Query notebook"'); - expect(html).toContain('aria-label="Copy all SQL"'); - expect(html).toContain("totals.sql"); - expect(html).toContain("details.sql"); -}); - -test("read-only SQL keeps literals and query whitespace intact", () => { - expect(formatSql(" SELECT 'FROM orders' AS label\n FROM orders ")).toBe( - "SELECT 'FROM orders' AS label\n FROM orders", - ); -}); - -test("category color follows identity and numeric metric formats use their units", () => { - expect(chartColor("insights")).toBe(chartColor("insights")); - expect(formatMetric(0.125, { kind: "ratio" })).toBe("12.5%"); - expect(formatMetric(12, { kind: "count" })).toBe("12"); - expect(formatMetric(12, { kind: "currency", currency: "USD" })).toBe("$12.00"); -}); - -test("named query registry rejects ambiguous evidence names", () => { - expect(defineQueryNames({ totals: "order-totals" }).totals).toBe("order-totals"); - expect(() => defineQueryNames({ first: "same", second: "same" })).toThrow("unique"); -}); - -test("shortcut labels and accessible keys include optional Shift", () => { - const shortcut = { modifier: "alt", shift: true, code: "KeyK", key: "K" } as const; - expect(["⌥⇧K", "Alt+Shift+K"]).toContain(shortcutLabel(shortcut)); - expect(ariaKeyShortcuts(shortcut)).toBe("Alt+Shift+K"); -}); - -test("app variables validate URLs and keep date presets relative", () => { - const definitions = defineAppVariables({ - search: textVariable({ key: "q" }), - member: selectVariable({ key: "member", defaultValue: "all", values: ["all", "alice"] }), - }); - expect(definitions.search.read(new URLSearchParams("q=build"))).toBe("build"); - expect(definitions.search.write("")).toEqual({ q: null }); - expect(definitions.member.read(new URLSearchParams("member=unknown"))).toBe("all"); - expect(() => - defineAppVariables({ first: textVariable({ key: "q" }), second: textVariable({ key: "q" }) }), - ).toThrow("duplicate URL key"); - - let sourceEnd = "2020-01-07"; - const contract = defineDateRangeContract({ - minDate: "2020-01-01", - maxDate: () => sourceEnd, - maxRangeDays: 7, - timeZone: "UTC", - }); - const period = dateRangeVariable({ - key: "period", - contract, - defaultValue: { kind: "preset", id: "last-3" }, - }); - const selection = period.read(new URLSearchParams("period=last-3")); - expect(period.resolve(selection)).toEqual({ start: "2020-01-05", end: "2020-01-07" }); - expect(contract.parse(period.resolve(selection))).toEqual(period.resolve(selection)); - expect(contract.period(period.resolve(selection))).toEqual({ - kind: "calendar", - start: "2020-01-05", - end: "2020-01-07", - timeZone: "UTC", - }); - expect(period.write(selection)).toEqual({ period: null, start: null, end: null }); - sourceEnd = "2020-01-08"; - expect(period.resolve(selection)).toEqual({ start: "2020-01-06", end: "2020-01-08" }); - expect(period.read(new URLSearchParams("start=2020-01-06&end=2020-01-08"))).toEqual({ - kind: "dates", - start: "2020-01-06", - end: "2020-01-08", - }); - expect(period.read(new URLSearchParams("period=last-90"))).toEqual(period.defaultValue); - expect(dateRangeControl(period, selection, () => {}).value).toEqual({ - start: "2020-01-06", - end: "2020-01-08", - }); -}); - -test("date comparison is opt-in, URL-backed, and bounded by source coverage", () => { - const period = dateRangeVariable({ - key: "period", - comparison: true, - contract: defineDateRangeContract({ - minDate: "2026-03-01", - maxDate: "2026-03-31", - maxRangeDays: 31, - timeZone: "UTC", - }), - defaultValue: { kind: "dates", start: "2026-03-10", end: "2026-03-12" }, - }); - const selected = period.read( - new URLSearchParams("start=2026-03-10&end=2026-03-12&compare=previous"), - ); - expect(selected).toEqual({ - kind: "dates", - start: "2026-03-10", - end: "2026-03-12", - comparison: "previous", - }); - expect(period.previous(selected)).toEqual({ start: "2026-03-07", end: "2026-03-09" }); - expect(period.comparisonRange(selected)).toEqual({ start: "2026-03-07", end: "2026-03-09" }); - expect(period.comparisonRange(period.defaultValue)).toBeNull(); - expect(period.write(selected)).toEqual({ - period: null, - start: "2026-03-10", - end: "2026-03-12", - compare: "previous", - }); - expect( - period.read(new URLSearchParams("start=2026-03-01&end=2026-03-03&compare=previous")), - ).toEqual({ - kind: "dates", - start: "2026-03-01", - end: "2026-03-03", - }); - const changes: unknown[] = []; - const control = dateRangeControl(period, selected, (next) => changes.push(next)); - expect(control.comparison?.range).toEqual({ start: "2026-03-07", end: "2026-03-09" }); - control.onChange({ start: "2026-03-01", end: "2026-03-03" }); - expect(changes).toEqual([{ kind: "dates", start: "2026-03-01", end: "2026-03-03" }]); -}); - -test("operation routes decode one path segment and client errors remain useful", async () => { - const operation = { - checks: [{}], - input: (value: unknown) => value, - output: (value: unknown) => value, - run: async () => ({ count: 1 }), - policy: { maxQueryRows: 1, maxDurationMs: 1000 }, - }; - const handler = createDataHandler({ "usage / team": operation }, async () => ({ - lakehouse: { queryAll: async () => ({ columns: [], rows: [] }) }, - canDiscloseSql: false, - })); - const response = await handler( - new Request("http://localhost/api/data/usage%20%2F%20team", { - method: "POST", - headers: { "content-type": "application/json" }, - body: "{}", - }), - ); - expect(response.status).toBe(200); - expect((await response.json()).data).toEqual({ count: 1 }); - const client = createDataClient({ - fetch: (async () => - new Response("bad gateway", { status: 502 })) as unknown as typeof fetch, - }); - expect(client.query("usage", {})).rejects.toMatchObject({ - name: "DataAppError", - code: "request_failed", - message: "Could not load data.", - }); - expect(DataAppError.name).toBe("DataAppError"); -}); diff --git a/data-app/runtime/tests/composition.tsx b/data-app/runtime/tests/composition.tsx deleted file mode 100644 index 6b871840..00000000 --- a/data-app/runtime/tests/composition.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { - AboutData, - AppFooter, - AppHeader, - AppLayout, - AppScope, - AppToolbar, - VariableBar, - Button, - VisualizationWidget, - DateRangePicker, - dateRangeControl, - dateRangeVariable, - defineAppVariables, - HelpPopover, - GlossaryExplanation, - MetricWidget, - PeriodSummary, - PlayStory, - RefreshRegion, - Sheet, - Skeleton, - Stack, - StatusPanel, - ThemeSelector, - Tooltip, - TooltipProvider, - UpdatedAt, - useAppVariables, - defineDataContext, - createDataContext, - evidenceFor, -} from "../src/react/ui/index.ts"; -import type { AppToolbarProps, PlayStoryProps } from "../src/react/ui/index.ts"; -import { defineDateRangeContract, defineQueryNames } from "../src/core/contract.ts"; - -const toolbarProps = { - refresh: { refreshing: false, onRefresh: () => {} }, -} satisfies AppToolbarProps; -const empty = { glossary: { title: "No terms" }, queries: { title: "No queries" } }; -const storyProps = { - title: "Story", - steps: [], - dataContext: null!, - empty, -} satisfies PlayStoryProps; -const dataContext = defineDataContext({ - description: "Orders", - glossary: { orders: { term: "Orders", definition: "Completed orders." } }, -}); -const queryNames = defineQueryNames({ totals: "order-totals" }); -const evidence = createDataContext(queryNames)(dataContext).evidence({ - id: "orders", - glossaryIds: ["orders"], - queryNames: [queryNames.totals], -}); -// @ts-expect-error A widget cannot refer to a glossary entry absent from this context. -evidenceFor(dataContext)({ id: "missing", glossaryIds: ["unknown"] }); -// @ts-expect-error A widget cannot refer to a query absent from the named query registry. -evidenceFor(dataContext, queryNames)({ id: "missing-query", queryNames: ["other-query"] }); -const variables = defineAppVariables({ - period: dateRangeVariable({ - key: "period", - contract: defineDateRangeContract({ - minDate: "2020-01-01", - maxDate: "2020-01-07", - maxRangeDays: 7, - timeZone: "UTC", - }), - defaultValue: { kind: "preset", id: "last-3" }, - }), -}); - -export function CompositionCheck() { - const appVariables = useAppVariables(variables); - const range = variables.period.resolve(appVariables.values.period); - const dateRange = dateRangeControl(variables.period, appVariables.values.period, (value) => - appVariables.set("period", value), - ); - void range; - return ( - - - } - toolbar={} - headingProps={{ className: "heading" }} - > - Subtitle - - - } - > - - - - {}} - className="dates" - calendarFooter="Choose dates" - /> - - } /> - - - - - Explain} - className="metric" - data-testid="metric" - /> - - {}}> - More detail - - - ↻ - - } - /> - - - - Content - - - {}} isDisabled className="dates" /> - - Details - - - - - Context - - {}} title="Details" className="sheet"> - Body - - Save} - footer="Notes" - launcherProps={{ className: "launcher" }} - dialogProps={{ className: "slides" }} - /> - - - - - - - - - - ); -} diff --git a/data-app/runtime/tests/data-identifiers.test.tsx b/data-app/runtime/tests/data-identifiers.test.tsx deleted file mode 100644 index 9b3bcb14..00000000 --- a/data-app/runtime/tests/data-identifiers.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { expect, test } from "bun:test"; -import { renderToStaticMarkup } from "react-dom/server"; -import { defineDataIdentifiers } from "../src/react/ui/data-identifiers.tsx"; - -const identifiers = defineDataIdentifiers({ - tables: { - events: { catalog: "product_analytics", schema: "analytics", name: "events" }, - }, - columns: { - identityUuid: { table: "events", name: "identity_uuid" }, - }, -}); - -test("data identifiers render exact source names from compact IDs", () => { - const { DataIdentifier } = identifiers; - const html = renderToStaticMarkup( -

- uses - . -

, - ); - expect(html.replace(/<[^>]*>/g, "")).toContain( - "product_analytics.analytics.events uses identity_uuid.", - ); - expect(html).toContain('data-kind="table"'); - expect(html).toContain('data-kind="column"'); - expect(html).toContain('title="product_analytics.analytics.events.identity_uuid"'); - expect(identifiers.definitions["columns.events.identityUuid"]).toEqual({ - kind: "column", - tableId: "tables.events", - table: { catalog: "product_analytics", schema: "analytics", name: "events" }, - name: "identity_uuid", - }); -}); - -test("aliases cannot obscure the compact ID structure", () => { - expect(() => - defineDataIdentifiers({ - tables: { "events.archive": { catalog: "a", schema: "b", name: "events" } }, - columns: {}, - }), - ).toThrow("Invalid data identifier alias events.archive."); -}); - -test("unknown references fail rather than rendering misleading text", () => { - const { DataIdentifier } = identifiers; - expect(() => - renderToStaticMarkup(), - ).toThrow("Unknown data identifier tables.missing."); -}); - -// @ts-expect-error Unknown reference IDs must fail app typechecking. -const unknownIdentifier = ; -void unknownIdentifier; - -function invalidIdentifierDefinition() { - const unknownTable = defineDataIdentifiers({ - tables: { events: { catalog: "a", schema: "b", name: "c" } }, - // @ts-expect-error A column must name a registered table. - columns: { id: { table: "missing", name: "id" } }, - }); - return unknownTable; -} -void invalidIdentifierDefinition; diff --git a/data-app/runtime/tests/format.test.ts b/data-app/runtime/tests/format.test.ts deleted file mode 100644 index 13ef5048..00000000 --- a/data-app/runtime/tests/format.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { expect, test } from "bun:test"; -import { formatNumber, formatCount, formatPercent, formatDateRange } from "../src/core/format.ts"; -test("number formatting distinguishes counts and ratios", () => { - expect(formatNumber(12.345, { maximumFractionDigits: 2 })).toBe("12.35"); - expect(formatNumber(-0)).toBe("0"); - expect(formatCount(12_345)).toBe("12,345"); - expect(formatCount(12_345, { compact: true })).toBe("12.3K"); - expect(formatCount(12.5)).toBe("—"); - expect(formatPercent(0.116)).toBe("11.6%"); - expect(formatPercent(0.0012)).toBe("0.12%"); - expect(formatPercent(0.00002)).toBe("<0.01%"); - expect(formatPercent(null)).toBe("—"); -}); - -test("calendar ranges stay compact without hiding their year", () => { - expect(formatDateRange({ start: "2026-09-25", end: "2026-09-27" })).toBe("Sep 25–27, 2026"); - expect(formatDateRange({ start: "2026-08-30", end: "2026-09-28" })).toBe("Aug 30–Sep 28, 2026"); - expect(formatDateRange({ start: "2025-12-30", end: "2026-01-02" })).toBe( - "Dec 30, 2025–Jan 2, 2026", - ); - expect(formatDateRange({ start: "2026-09-28", end: "2026-09-28" })).toBe("Sep 28, 2026"); -}); diff --git a/data-app/runtime/tests/harness.test.tsx b/data-app/runtime/tests/harness.test.tsx deleted file mode 100644 index 119424ec..00000000 --- a/data-app/runtime/tests/harness.test.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { expect, test } from "bun:test"; -import { renderToStaticMarkup } from "react-dom/server"; -import { defineDateRangeContract, defineQueryNames } from "../src/core/contract.ts"; -import { defineDataContent } from "../src/react/content.ts"; -import { describeViewInput, type DataViewDefinition } from "../src/react/view.ts"; -import { createDataContext } from "../src/react/ui/data-context.ts"; -import { dateRangeVariable, defineAppVariables, textVariable } from "../src/react/ui/variables.ts"; -import { ContentSkeleton } from "../src/react/ui/ContentSkeleton.tsx"; -import { WidgetViewTabs } from "../src/react/ui/WidgetViewTabs.tsx"; -import { calendarMetricComparison } from "../src/react/ui/comparison.ts"; -import { MetricWidget } from "../src/react/ui/MetricWidget.tsx"; - -const calendar = defineDateRangeContract({ - minDate: "2026-01-01", - maxDate: "2026-03-31", - maxRangeDays: 31, - timeZone: "UTC", -}); - -test("date requests derive a comparison and reject forged or unavailable ranges", () => { - const variable = dateRangeVariable({ - key: "period", - contract: calendar, - comparison: true, - defaultValue: { kind: "dates", start: "2026-03-01", end: "2026-03-03" }, - }); - const request = variable.input({ ...variable.defaultValue, comparison: "previous" }); - expect(request.comparison).toEqual({ start: "2026-02-26", end: "2026-02-28" }); - expect(calendar.parseRequest(JSON.parse(JSON.stringify(request)))).toEqual(request); - expect(variable.describeInput(request)).toBe("Mar 1–3, 2026 UTC"); - expect(() => calendar.parseRequest({ ...request, comparison: request.range })).toThrow( - "preceding", - ); - expect(() => calendar.request({ start: "2026-01-01", end: "2026-01-03" }, true)).toThrow( - "coverage", - ); - expect(() => - calendar.parseRequest({ range: { start: "2026-02-30", end: "2026-03-03" } }), - ).toThrow(); - const definition = { - operation: "activity", - variables: { period: variable }, - input: ({ period }: { period: typeof request }) => period, - date: { variable: "period", input: (input: typeof request) => input }, - isEmpty: (data: { count: number }) => data.count === 0, - empty: { title: "No activity" }, - } satisfies DataViewDefinition< - "activity", - { period: typeof variable }, - typeof request, - { count: number } - >; - expect(describeViewInput(definition)(request)).toBe("Mar 1–3, 2026 UTC"); - expect(() => - describeViewInput({ ...definition, variables: { first: variable, second: variable } }), - ).toThrow("date range variable"); - expect(describeViewInput({ ...definition, describeInput: () => "custom period" })(request)).toBe( - "custom period", - ); -}); - -test("comparison labels follow displayed inputs and distinguish unavailable from zero", () => { - const input = calendar.request({ start: "2026-03-01", end: "2026-03-03" }, true); - const format = { kind: "count" } as const; - expect( - calendarMetricComparison({ ...input, comparison: null }, { current: 12, previous: 7, format }), - ).toBeUndefined(); - const available = calendarMetricComparison(input, { current: 12, previous: 0, format }); - expect(available).toMatchObject({ - current: { formattedValue: "12", periodLabel: "Mar 1–3, 2026" }, - previous: { value: 0, formattedValue: "0", periodLabel: "Feb 26–28, 2026" }, - }); - const unavailable = calendarMetricComparison(input, { current: 12, previous: null, format }); - expect(unavailable?.previous).toMatchObject({ value: null, formattedValue: "Not available" }); - expect( - renderToStaticMarkup( - , - ), - ).toContain("Previous period unavailable"); -}); - -test("variables cannot overwrite navigation, inspection, or presentation routes", () => { - for (const key of ["view", "about", "tab", "present", "step"]) { - expect(() => defineAppVariables({ search: textVariable({ key }) })).toThrow("reserved"); - } - expect(() => - defineAppVariables({ first: textVariable({ key: "q" }), second: textVariable({ key: "q" }) }), - ).toThrow("duplicate"); -}); - -test("context validates glossary queries and binds widget evidence to its registries", () => { - const define = createDataContext(defineQueryNames({ orders: "orders" })); - const context = define({ - description: "Orders", - glossary: { - completed: { term: "Completed", definition: "Completed orders", queryNames: ["orders"] }, - }, - }); - expect( - context.evidence({ id: "total", glossaryIds: ["completed"], queryNames: ["orders"] }) - .glossaryIds, - ).toEqual(["completed"]); - expect(() => context.evidence({ id: " ", glossaryIds: ["completed"] })).toThrow( - "Evidence needs a nonempty ID", - ); - expect( - context.storyStep({ - id: "finding", - headline: "Orders rose", - visual:

12 orders

, - glossaryIds: ["completed"], - queryNames: ["orders"], - }).queryNames, - ).toEqual(["orders"]); - expect(() => - context.storyStep({ - id: "bad", - headline: "Bad", - visual: null, - // @ts-expect-error Story query references must belong to the registered query names. - queryNames: ["unknown"], - }), - ).toThrow("Unknown query"); - expect(() => - define({ - description: "Orders", - glossary: { - // @ts-expect-error Unknown glossary queries fail at authoring time and at runtime. - bad: { term: "Bad", definition: "Bad", queryNames: ["unknown"] }, - }, - }), - ).toThrow("Unknown query"); -}); - -test("one composition renders skeleton structure without any result values", () => { - const content = defineDataContent<{ count: number }, { period: string }>((state) => ( -
-

Orders

- {state.loading ? ( - - ) : ( -

- {state.data.count} in {state.input.period} -

- )} -
- )); - const loading = renderToStaticMarkup(content.loading); - expect(loading.match(/class="altertable-content-skeleton-row"/g)).toHaveLength(6); - expect(loading).not.toContain("120"); - expect(renderToStaticMarkup(content.children({ count: 120 }, { period: "March" }))).toContain( - "120 in March", - ); -}); - -test("an empty widget tab renders its authored fallback", () => { - const markup = renderToStaticMarkup( - {}} - views={[ - { - id: "orders", - label: "Orders", - isEmpty: true, - empty: { title: "No orders" }, - content:

Must not render

, - }, - ]} - />, - ); - expect(markup).toContain("No orders"); - expect(markup).not.toContain("Must not render"); -}); diff --git a/data-app/runtime/tests/server.test.ts b/data-app/runtime/tests/server.test.ts deleted file mode 100644 index ad2d297a..00000000 --- a/data-app/runtime/tests/server.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { expect, test } from "bun:test"; -import * as runtime from "../src/server/index.ts"; -import * as contract from "../src/core/contract.ts"; -import * as local from "../src/server/local.ts"; -import * as appearance from "../src/core/appearance.ts"; -test("runtime validates input, bounds rows, and hides query failures", async () => { - expect(appearance.parseAppearance({ density: "compact", cornerRadius: "small" })).toMatchObject({ - density: "compact", - cornerRadius: "small", - }); - expect(() => appearance.parseAppearance({ cornerRadius: "roundish" })).toThrow( - "Invalid appearance", - ); - const source = local.localLakehouse( - { ALTERTABLE_LAKEHOUSE_USERNAME: "user", ALTERTABLE_LAKEHOUSE_PASSWORD: "secret" }, - Object.assign(async () => new Response("private upstream detail", { status: 401 }), { - preconnect() {}, - }), - ); - try { - await source.queryAll("SELECT 1", { limit: 1, signal: new AbortController().signal }); - throw new Error("Expected an authorization failure."); - } catch (error) { - expect(error).toMatchObject({ reason: "unauthorized", status: 401 }); - } - const proxied = local.localLakehouse( - { - ALTERTABLE_DATA_PROXY_URL: "http://127.0.0.1:1234", - ALTERTABLE_DATA_PROXY_TOKEN: "run-token", - }, - Object.assign( - async (url: RequestInfo | URL, options?: RequestInit) => { - expect(url instanceof Request ? url.url : url.toString()).toBe( - "http://127.0.0.1:1234/query", - ); - expect(options?.headers).toMatchObject({ authorization: "Bearer run-token" }); - return new Response('{"query_id":"q1"}\n["value"]\n[1]\n'); - }, - { preconnect() {} }, - ), - ); - expect( - await proxied.queryAll("SELECT 1", { limit: 1, signal: new AbortController().signal }), - ).toMatchObject({ rows: [[1]], queryId: "q1" }); - const operation = contract.defineOperation({ - checks: [1], - queryNames: contract.defineQueryNames({ totals: "totals" }), - input(value: unknown) { - if (value !== 1) throw new Error("Bad input"); - return 1; - }, - output(value: unknown) { - return value as number; - }, - policy: { maxQueryRows: 1, maxDurationMs: 1000, exposeSql: true }, - async run({ - lakehouse, - signal, - }: { - lakehouse: { - queryAll: ( - sql: string, - options: { limit: number; signal: AbortSignal; name?: string }, - ) => Promise<{ rows: unknown[][] }>; - }; - signal: AbortSignal; - }) { - const result = await lakehouse.queryAll("SELECT 1", { limit: 20, signal, name: "totals" }); - return result.rows.length; - }, - }); - let requestedLimit = 0; - const handle = runtime.createDataHandler({ totals: operation }, async () => ({ - canDiscloseSql: true, - lakehouse: { - async queryAll(_statement: string, options: { limit: number }) { - requestedLimit = options.limit; - return { columns: [], rows: [[1]], queryId: "query-1" }; - }, - }, - })); - const request = (value: unknown) => - new Request("http://localhost/api/data/totals", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(value), - }); - const valid = await handle(request(1)); - expect(valid.status).toBe(200); - const response = await valid.json(); - expect(response).not.toHaveProperty("evidence"); - expect(response).toMatchObject({ - data: 1, - queryIds: ["query-1"], - queries: [{ name: "totals", statement: "SELECT 1", queryId: "query-1" }], - }); - expect(requestedLimit).toBe(1); - const unregistered = runtime.createDataHandler( - { - totals: { - ...operation, - run: async ({ lakehouse, signal }) => { - await lakehouse.queryAll("SELECT 1", { limit: 1, signal, name: "other" }); - return 1; - }, - }, - }, - async () => ({ - canDiscloseSql: true, - lakehouse: { queryAll: async () => ({ columns: [], rows: [] }) }, - }), - ); - expect((await unregistered(request(1))).status).toBe(502); - expect( - ( - await handle( - new Request("http://localhost/api/data/totals", { - method: "POST", - headers: { "content-type": "text/plain" }, - body: "1", - }), - ) - ).status, - ).toBe(415); - expect( - ( - await handle( - new Request("http://localhost/api/data/totals", { - method: "POST", - headers: { "content-type": "application/json", origin: "http://other.example" }, - body: "1", - }), - ) - ).status, - ).toBe(403); - expect( - ( - await handle( - new Request("http://localhost/api/data/totals", { - method: "POST", - headers: { - "content-type": "application/json", - origin: "http://localhost", - "sec-fetch-site": "cross-site", - }, - body: "1", - }), - ) - ).status, - ).toBe(403); - expect( - ( - await handle( - new Request("http://localhost/api/data/totals", { - method: "POST", - headers: { - "content-type": "application/json", - origin: "http://localhost", - "sec-fetch-site": "same-origin", - "sec-fetch-mode": "cors", - }, - body: "1", - }), - ) - ).status, - ).toBe(200); - const restricted = runtime.createDataHandler({ totals: operation }, async () => ({ - canDiscloseSql: false, - lakehouse: { - async queryAll() { - return { columns: [], rows: [[1]] }; - }, - }, - })); - expect(await (await restricted(request(1))).json()).not.toHaveProperty("queries"); - expect((await handle(request(2))).status).toBe(400); - expect((await handle(new Request("http://localhost/api/data/other"))).status).toBe(404); - const failing = runtime.createDataHandler({ totals: operation }, async () => ({ - canDiscloseSql: false, - lakehouse: { - async queryAll() { - throw new Error("private upstream detail"); - }, - }, - })); - const failure = await failing(request(1)); - expect(failure.status).toBe(502); - expect(JSON.stringify(await failure.json())).not.toContain("private upstream detail"); - const sourceFailure = runtime.createDataHandler({ totals: operation }, async () => ({ - canDiscloseSql: false, - lakehouse: source, - })); - const upstreamFailure = await sourceFailure(request(1)); - expect(upstreamFailure.status).toBe(502); - expect(await upstreamFailure.json()).toMatchObject({ - error: { code: "source_unauthorized", message: expect.stringContaining("altertable login") }, - }); - const forbidden = runtime.createDataHandler({ totals: operation }, async () => { - throw new Error("private authorization detail"); - }); - const denied = await forbidden(request(1)); - expect(denied.status).toBe(403); - expect(JSON.stringify(await denied.json())).not.toContain("private authorization detail"); -}); diff --git a/data-app/runtime/tests/view-composition.tsx b/data-app/runtime/tests/view-composition.tsx deleted file mode 100644 index 50ae717d..00000000 --- a/data-app/runtime/tests/view-composition.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import type { DataOperation, DateRangeRequest } from "../src/core/contract.ts"; -import { defineDateRangeContract } from "../src/core/contract.ts"; -import { createDataClient } from "../src/client/index.ts"; -import { createDataHooks } from "../src/react/index.ts"; -import { dateRangeVariable, textVariable } from "../src/react/ui/variables.ts"; -import type { MetricWidgetProps } from "../src/react/ui/MetricWidget.tsx"; -import type { DataSectionProps } from "../src/react/ui/DataSection.tsx"; -import type { DataAppProps } from "../src/react/ui/DataApp.tsx"; - -const { defineDataView } = createDataHooks<{ - activity: DataOperation; -}>(createDataClient()); -const period = dateRangeVariable({ - key: "period", - defaultValue: { kind: "preset", id: "last-7" }, - contract: defineDateRangeContract({ maxRangeDays: 30, timeZone: "UTC" }), -}); -defineDataView({ - operation: "activity", - variables: { period }, - input: ({ period }) => period, - date: { variable: "period", input: (input) => input }, - isEmpty: (data) => data.count === 0, - empty: { title: "No activity" }, -}); -defineDataView({ - operation: "activity", - variables: { period }, - // @ts-expect-error Operation input must match the selected operation. - input: () => ({ count: 3 }), - describeInput: period.describeInput, - isEmpty: () => false, - empty: { title: "Empty" }, -}); -// @ts-expect-error Without a date variable, the input needs an authored description. -defineDataView({ - operation: "activity", - variables: { search: textVariable({ key: "search" }) }, - input: () => ({ range: { start: "2026-01-01", end: "2026-01-02" }, comparison: null }), - isEmpty: (data) => data.count === 0, - empty: { title: "No activity" }, -}); -// @ts-expect-error Numbers require a format. -const metric: MetricWidgetProps = { label: "Orders", value: 123 }; -// @ts-expect-error Secondary requests require an empty state. -const section: DataSectionProps = { view: { kind: "loading" }, children: () => null }; -// @ts-expect-error Primary requests require a fallback on the request or shell. -const app: DataAppProps = { - config: { appearance: {}, title: "Test", scope: { organization: "a", environment: "b" } }, - dataContext: { description: "Test", glossary: {} }, - aboutEmpty: { glossary: { title: "Empty" }, queries: { title: "Empty" } }, - request: { view: { kind: "loading" }, refetch() {} }, - children: () => null, -}; -void [metric, section, app]; - -import { defineOperation, defineQueryNames } from "../src/core/contract.ts"; -import { createDataContext } from "../src/react/ui/data-context.ts"; -import { MetricWidget } from "../src/react/ui/MetricWidget.tsx"; -import { WidgetViewTabs } from "../src/react/ui/WidgetViewTabs.tsx"; -const queries = defineQueryNames({ actions: "actions" }); -defineOperation({ - queryNames: queries, - input: () => ({}), - output: () => true, - checks: [{}], - policy: { maxQueryRows: 1, maxDurationMs: 1000 }, - async run({ query }) { - // @ts-expect-error Query identity is scoped to the operation registry. - await query("unknown", "SELECT 1"); - return true; - }, -}); -const context = createDataContext(queries)({ - description: "Activity", - glossary: { actions: { term: "Actions", definition: "Actions", queryNames: [queries.actions] } }, -}); -const actions = context.metric({ id: "actions", glossaryId: "actions", format: { kind: "count" } }); -// @ts-expect-error Metric definitions bind glossary identity. -context.metric({ id: "missing", glossaryId: "missing", format: { kind: "count" } }); -const conflictingMetric = ( - // @ts-expect-error A bound metric cannot supply a second current value. - -); -const tabs = [ - { id: "actions", label: "Actions", content: null, isEmpty: true, empty: { title: "Empty" } }, -] as const; -const invalidTabs = ( - // @ts-expect-error Selection must belong to the declared tabs. - {}} /> -); -void [conflictingMetric, invalidTabs]; - -dateRangeVariable({ - key: "invalid-default", - contract: defineDateRangeContract({ maxRangeDays: 30, timeZone: "UTC" }), - // @ts-expect-error Comparison is activated by the reader, never by the app default. - defaultValue: { kind: "preset", id: "last-7", comparison: "previous" }, -}); diff --git a/data-app/runtime/tsconfig.json b/data-app/runtime/tsconfig.json deleted file mode 100644 index efa78b44..00000000 --- a/data-app/runtime/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "Preserve", - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "jsx": "react-jsx", - "strict": true, - "noEmit": true, - "skipLibCheck": true, - "types": [ - "bun" - ] - }, - "include": [ - "src", - "tests" - ] -} diff --git a/data-app/starter/AGENTS.md b/data-app/starter/AGENTS.md index bdb9c0e2..28159a4a 100644 --- a/data-app/starter/AGENTS.md +++ b/data-app/starter/AGENTS.md @@ -2,15 +2,17 @@ Inspect source data, time coverage and existing definitions before choosing an exploration. For broad questions, investigate complementary angles. Build conclusions from observed results; do not present association as cause. Keep copy concise and relevant to the reader. +Read `node_modules/@altertable/data-app/AGENTS.md` and its [app authoring guide](node_modules/@altertable/data-app/docs/app-authoring.md) before changing the app. Run `bun install --frozen-lockfile` if the package is not installed. + | Task | App-owned files | Read next | | --- | --- | --- | | Connect a source, define a query, or change inputs | `app.json`, `src/operations.ts`, optional `src/variables.ts` | [Data and operation authoring](docs/data.md) | | Build a view, filters, request states, or Present steps | `src/App.tsx`, `src/styles.css` | [View authoring](docs/views.md) | -| Explain the exploration or define terms | `src/data-context.ts` or `.tsx` | [Data guidance](docs/data.md), [context APIs](.altertable/runtime/README.md#find-ui-by-task) | -| Change title, scope, or brand | `app.json` | [Config and appearance APIs](.altertable/runtime/README.md#entry-points) | -| Find a component, hook, or parser | Read `.altertable/runtime/` | [Runtime API map](.altertable/runtime/README.md) | -| Replace the local server with hosting | `src/server.ts` | [Server authorization boundary](.altertable/runtime/src/server/index.ts) | +| Explain the exploration or define terms | `src/data-context.ts` or `.tsx` | [Data guidance](docs/data.md), [context APIs](node_modules/@altertable/data-app/docs/react.md#find-ui-by-task) | +| Change title, scope, or brand | `app.json` | [Config and appearance APIs](node_modules/@altertable/data-app/docs/app-authoring.md) | +| Find a component, hook, or parser | Read the installed package docs | [Package authoring guide](node_modules/@altertable/data-app/docs/app-authoring.md) | +| Replace the local server with hosting | `src/server.ts` | [Server authorization boundary](node_modules/@altertable/data-app/docs/server.md) | -Edit app-owned source and use public runtime APIs. The generated connectivity screen is scaffolding, not an example analysis. +Edit app-owned source and use public package APIs. Do not edit installed package files. The generated connectivity screen is scaffolding, not an example analysis. Run `altertable app check --lakehouse` with the matching profile, then inspect the exploration at phone and desktop widths. Verify that its findings and interactions answer the user's question. diff --git a/data-app/starter/README.md b/data-app/starter/README.md index b69c9715..071883e2 100644 --- a/data-app/starter/README.md +++ b/data-app/starter/README.md @@ -13,18 +13,17 @@ altertable app dev ``` The CLI prints the local URL. Use `altertable app dev --port 3022` to choose a port. -CLI runtime contributors can use `altertable app dev --watch-runtime` to upgrade the generated runtime and restart the preview when runtime source changes. The watcher stops if a generated runtime file was edited. ## Author the app -Start with the [authoring router](AGENTS.md) for data and view changes, or the [runtime API map](.altertable/runtime/README.md) to find components, hooks, and contracts. +Start with the [authoring router](AGENTS.md) for data and view changes, or the [package authoring guide](node_modules/@altertable/data-app/docs/app-authoring.md) to find components, hooks, and contracts. ## Check and upgrade Run `altertable app check` to check the app locally. Use `altertable app check --lakehouse` to execute each operation's declared `checks` against the selected profile. -After updating the CLI, run `altertable app upgrade` and check again. +`altertable app upgrade` pins the package version tested with your CLI and updates the lockfile. It preserves app code and does not downgrade a newer installed package. Restart a running preview after upgrading. ## Version control -Commit `.altertable/runtime/`, including its integrity record, together with `package.json` and `bun.lock`. It is a local package required by a fresh clone. Keep custom code in `src/` and use `altertable app upgrade` to replace the managed runtime. +Commit app source, `package.json`, and `bun.lock`. A fresh clone installs `@altertable/data-app` from npm with `bun install --frozen-lockfile`; installation needs registry access or a populated Bun cache. Keep custom code in `src/`. diff --git a/data-app/starter/bun.lock b/data-app/starter/bun.lock index 1fe125f6..3e7d0967 100644 --- a/data-app/starter/bun.lock +++ b/data-app/starter/bun.lock @@ -5,7 +5,7 @@ "": { "name": "data-app-starter", "dependencies": { - "@altertable/data-app": "file:.altertable/runtime", + "@altertable/data-app": "0.59.1", "react": "19.3.0", "react-dom": "19.3.0", }, @@ -21,7 +21,7 @@ }, }, "packages": { - "@altertable/data-app": ["@altertable/data-app@file:.altertable/runtime", { "dependencies": { "@floating-ui/react": "0.27.20", "@internationalized/date": "3.12.4", "@tanstack/react-query": "5.104.0", "fuzzysort": "4.0.2", "lucide-react": "1.48.0", "react-aria-components": "1.21.1" }, "devDependencies": { "@types/bun": "1.4.2", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "oxfmt": "0.70.0", "oxlint": "1.85.0", "oxlint-tsgolint": "7.0.2003", "react": "19.3.0", "react-dom": "19.3.0", "typescript": "7.0.2" }, "peerDependencies": { "react": ">=19", "react-dom": ">=19" } }], + "@altertable/data-app": ["@altertable/data-app@0.59.1", "", { "dependencies": { "@floating-ui/react": "0.27.20", "@internationalized/date": "3.12.4", "@tanstack/react-query": "5.104.0", "fuzzysort": "4.0.2", "lucide-react": "1.48.0", "react-aria-components": "1.21.1" }, "peerDependencies": { "react": ">=19.2.0", "react-dom": ">=19.2.0" } }, "sha512-XkCXlAI32FjewiHQ9+H0bahVWG0FgfBa9SxMd0mbkOYNba/lwK9pcwaGni3dqOtTvKYNAKI1ylI+OeyJqpxwpQ=="], "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], diff --git a/data-app/starter/docs/data.md b/data-app/starter/docs/data.md index 4a3ba579..bdddda15 100644 --- a/data-app/starter/docs/data.md +++ b/data-app/starter/docs/data.md @@ -10,6 +10,6 @@ Inspect catalogs, time coverage, and existing definitions before selecting the e | `src/data-context.ts` or `.tsx` | Exploration description, glossary, named queries, and optional source identifiers. | | `src/App.tsx` | Questions, filters, result states, cards, and optional story. | -[Execute named queries](../.altertable/runtime/README.md#execute-named-queries) and [bind evidence](../.altertable/runtime/README.md#bind-evidence) using the runtime contracts. Prefer a date variable for ongoing questions; choose a fixed period for deliberate historical explorations. +[Execute named queries](../node_modules/@altertable/data-app/docs/contract.md) and [bind evidence](../node_modules/@altertable/data-app/docs/react.md#bind-evidence) using the runtime contracts. Prefer a date variable for ongoing questions; choose a fixed period for deliberate historical explorations. `app check --lakehouse` verifies declared operations and known profile scope. Environment-only credentials may not identify an organization; verify source identity during discovery when that metadata is unavailable. diff --git a/data-app/starter/docs/views.md b/data-app/starter/docs/views.md index 43304e57..00ca3107 100644 --- a/data-app/starter/docs/views.md +++ b/data-app/starter/docs/views.md @@ -2,4 +2,4 @@ Lead with a supported finding and provide complementary ways to explore the question. Check actual content at phone and desktop widths in both themes. -[Bind a view](../.altertable/runtime/README.md#bind-a-view) for variables, request states, shared skeletons, metrics and comparisons. [Bind evidence](../.altertable/runtime/README.md#bind-evidence) for glossary definitions and Present steps. The [UI map](../.altertable/runtime/README.md#find-ui-by-task) routes other components to their types. +[Bind a view](../node_modules/@altertable/data-app/docs/react.md#bind-a-view) for variables, request states, shared skeletons, metrics and comparisons. [Bind evidence](../node_modules/@altertable/data-app/docs/react.md#bind-evidence) for glossary definitions and Present steps. The [UI map](../node_modules/@altertable/data-app/docs/react.md#find-ui-by-task) routes other components to their types. diff --git a/data-app/starter/fixtures/main.tsx b/data-app/starter/fixtures/main.tsx index e9f9d39d..d3dd39fe 100644 --- a/data-app/starter/fixtures/main.tsx +++ b/data-app/starter/fixtures/main.tsx @@ -1,8 +1,13 @@ +import "@altertable/data-app/react/styles.css"; import { mountDataApp } from "@altertable/data-app/react"; import { useState } from "react"; import { createDataHooks } from "@altertable/data-app/react"; import { createDataClient } from "@altertable/data-app/client"; -import { connectionCheck, defineDateRangeContract } from "@altertable/data-app/contract"; +import { + connectionCheck, + connectionQueryNames, + defineDateRangeContract, +} from "@altertable/data-app/contract"; import { DataApp, dateRangeVariable, @@ -13,7 +18,7 @@ import { GlossaryDefinition, MetricWidget, Stack, - StorySection, + DataWidget, TableWidget, VisualizationWidget, } from "@altertable/data-app/react"; @@ -37,6 +42,7 @@ const identifiers = defineDataIdentifiers({ }); const { DataIdentifier } = identifiers; const dataContext = { + queryNames: connectionQueryNames, identifiers: identifiers.definitions, description: ( <> @@ -99,22 +105,20 @@ function Fixture() { } - story={{ - steps: [ - { - id: "orders", - headline: "Orders increased", - visual:

120 orders

, - glossaryIds: ["orders"], - }, - { - id: "customers", - headline: "More returning customers", - visual:

80 customers

, - glossaryIds: [], - }, - ], - }} + story={() => [ + { + id: "orders", + headline: "Orders increased", + visual:

120 orders

, + evidence: { id: "orders", glossaryIds: ["orders"] }, + }, + { + id: "customers", + headline: "More returning customers", + visual:

80 customers

, + evidence: { id: "customers", queryNames: [connectionQueryNames.connection] }, + }, + ]} > {() => ( @@ -140,20 +144,28 @@ function Fixture() { visual={

Feature reach

} /> - - } - visual={Daily orders

} />} - support={80 customers

} />} - /> + + +
+ +
+ + + Daily orders

} /> +
+ 80 customers

} + /> +
+
+
Short panel
diff --git a/data-app/starter/fixtures/view.tsx b/data-app/starter/fixtures/view.tsx index 5ff260c5..3d51b4c3 100644 --- a/data-app/starter/fixtures/view.tsx +++ b/data-app/starter/fixtures/view.tsx @@ -1,3 +1,4 @@ +import "@altertable/data-app/react/styles.css"; import { createDataClient } from "@altertable/data-app/client"; import { createDataHooks, diff --git a/data-app/starter/package.json b/data-app/starter/package.json index a9ab40d9..ce2629de 100644 --- a/data-app/starter/package.json +++ b/data-app/starter/package.json @@ -15,7 +15,7 @@ "build": "bun build src/index.html --outdir=dist --production" }, "dependencies": { - "@altertable/data-app": "file:.altertable/runtime", + "@altertable/data-app": "0.59.1", "react": "19.3.0", "react-dom": "19.3.0" }, diff --git a/data-app/starter/src/main.tsx b/data-app/starter/src/main.tsx index 840e7f72..6d36cdf8 100644 --- a/data-app/starter/src/main.tsx +++ b/data-app/starter/src/main.tsx @@ -1,3 +1,4 @@ +import "@altertable/data-app/react/styles.css"; import { mountDataApp } from "@altertable/data-app/react"; import { App } from "#app/App.tsx"; import app from "#config"; diff --git a/data-app/starter/src/server.ts b/data-app/starter/src/server.ts index 0cfbd8ec..265bd0e4 100644 --- a/data-app/starter/src/server.ts +++ b/data-app/starter/src/server.ts @@ -1,5 +1,5 @@ import page from "#app/index.html"; -import { serveLocalApp } from "@altertable/data-app/server"; +import { serveLocalApp } from "@altertable/data-app/server/bun"; import { operations } from "#app/operations.ts"; import app from "#config"; diff --git a/data-app/tests/bun.lock b/data-app/tests/bun.lock index 5a486138..54469dfa 100644 --- a/data-app/tests/bun.lock +++ b/data-app/tests/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "data-app-browser-tests", + "dependencies": { + "@altertable/data-app": "0.59.1", + }, "devDependencies": { "@playwright/test": "1.63.0", "@types/bun": "1.4.2", @@ -13,6 +16,24 @@ }, }, "packages": { + "@altertable/data-app": ["@altertable/data-app@0.59.1", "", { "dependencies": { "@floating-ui/react": "0.27.20", "@internationalized/date": "3.12.4", "@tanstack/react-query": "5.104.0", "fuzzysort": "4.0.2", "lucide-react": "1.48.0", "react-aria-components": "1.21.1" }, "peerDependencies": { "react": ">=19.2.0", "react-dom": ">=19.2.0" } }, "sha512-XkCXlAI32FjewiHQ9+H0bahVWG0FgfBa9SxMd0mbkOYNba/lwK9pcwaGni3dqOtTvKYNAKI1ylI+OeyJqpxwpQ=="], + + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], + + "@floating-ui/react": ["@floating-ui/react@0.27.20", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.9", "@floating-ui/utils": "^0.2.12", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + + "@internationalized/date": ["@internationalized/date@3.12.4", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ=="], + + "@internationalized/number": ["@internationalized/number@3.6.8", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-8UmMFia46DUt+k97zKd9fKWXcWHR+k8ae3eYzILETuT2KbIvLyOfac7zesw+sJdRAAZ7Q9pM1Mk22aXp2LD0Ig=="], + + "@internationalized/string": ["@internationalized/string@3.2.10", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA=="], + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@7.0.2003", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TgV33rXr6ueXBwvc+0nssUkTBSXHJxv77I8p4RCjDjCnvexHtmoPiudVRDfj3S3puMDdeQdHW6jdUgUPy3nr/w=="], "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@7.0.2003", "", { "os": "darwin", "cpu": "x64" }, "sha512-hY3FMAjIaPDdK3FNxyRXRfHPOFeoBn6dmnLyKZgQ2IbTTD1adXhl6PfCIqHhCPvurigv7nf7mYuWZZ19MmJzmg=="], @@ -27,6 +48,14 @@ "@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="], + "@react-types/shared": ["@react-types/shared@3.36.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-AzsuD9OfxTOZMMvTRhlN3oHBwOmFN7tDh27LzqmHt4+uOgPhJT7ZM7/kVs/8/o0WxayMUIk3hBmCFRHv1FUoag=="], + + "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], + + "@tanstack/query-core": ["@tanstack/query-core@5.104.0", "", {}, "sha512-JrC2r/JQlXt7khBSdUpsgxNvybzOg+aITa+ARRMlP2AFo93Y8vIqz077rp+e61mJetSZ0T6AJbwW1JHer1vrPQ=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.104.0", "", { "dependencies": { "@tanstack/query-core": "5.104.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-e1TZmDCQnWIfiDVryIHeA6Idj+Lfx1hORLOhXS/l5wcgvtVD4yYqbTDl378sGQKIi2cSgb0TEMC9cKK8GGoJEw=="], + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], "@types/node": ["@types/node@26.6.3", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg=="], @@ -71,16 +100,44 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "fuzzysort": ["fuzzysort@4.0.2", "", {}, "sha512-3c7yD6rK1EXIsqpcu1ZVzXJugODWNMzvUij6yTiziaAHrpNS8kzFOI9mJLE8pmG4K9JOaVgzQRgklfOEk8f4Tg=="], + + "lucide-react": ["lucide-react@1.48.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ=="], + "oxlint-tsgolint": ["oxlint-tsgolint@7.0.2003", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "7.0.2003", "@oxlint-tsgolint/darwin-x64": "7.0.2003", "@oxlint-tsgolint/linux-arm64": "7.0.2003", "@oxlint-tsgolint/linux-x64": "7.0.2003", "@oxlint-tsgolint/win32-arm64": "7.0.2003", "@oxlint-tsgolint/win32-x64": "7.0.2003" }, "bin": { "tsgolint": "./bin/tsgolint.js" } }, "sha512-VnK4zlqgmgq/7ZcjzCk/WpN8kKFsYGcB85Io9qT3wL4K8Un3RKmJkp793crNETieMusPMKOA4a+Mw2wbteI6TQ=="], "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], + + "react-aria": ["react-aria@3.52.1", "", { "dependencies": { "@internationalized/date": "^3.12.4", "@internationalized/number": "^3.6.8", "@internationalized/string": "^3.2.10", "@react-types/shared": "^3.36.1", "@swc/helpers": "^0.5.0", "aria-hidden": "^1.2.3", "clsx": "^2.0.0", "react-stately": "3.50.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-fdZZruC9/x/joCg0mhKGs5aHpwrXLCSZ4GOJmhhYyiE0ffyEsk9MLFt9LCOCF9tn8ErTD+UDQP4oDUSlZkmpCg=="], + + "react-aria-components": ["react-aria-components@1.21.1", "", { "dependencies": { "@internationalized/date": "^3.12.4", "@internationalized/string": "^3.2.10", "@react-types/shared": "^3.36.1", "@swc/helpers": "^0.5.0", "client-only": "^0.0.1", "react-aria": "3.52.1", "react-stately": "3.50.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-J88WflYY0z+EhLX0ce+GjFlQ3ag3ubIgfUTjpKSrBQBu92Xtl4Ub9o118HItUddtbk1Ido0jzyPy94/klZy1hg=="], + + "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], + + "react-stately": ["react-stately@3.50.0", "", { "dependencies": { "@internationalized/date": "^3.12.4", "@internationalized/number": "^3.6.8", "@internationalized/string": "^3.2.10", "@react-types/shared": "^3.36.1", "@swc/helpers": "^0.5.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-TnckvpDQGU0672wEaLZqdzvhuSeXWjgW6vijMWxiKOxc8CosQUfPGISdcZyfHqjX3XMjEtRxj4w9HumvX4h4mw=="], + + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + + "tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="], } } diff --git a/data-app/tests/package.json b/data-app/tests/package.json index 2faa5de5..735eb60c 100644 --- a/data-app/tests/package.json +++ b/data-app/tests/package.json @@ -5,13 +5,16 @@ "scripts": { "test": "playwright test", "typecheck": "tsc --noEmit", - "lint": "../runtime/node_modules/.bin/oxlint --type-aware *.ts", - "format:check": "../runtime/node_modules/.bin/oxfmt --check *.ts" + "lint": "../starter/node_modules/.bin/oxlint --type-aware *.ts", + "format:check": "../starter/node_modules/.bin/oxfmt --check *.ts" }, "devDependencies": { "@playwright/test": "1.63.0", "@types/bun": "1.4.2", "oxlint-tsgolint": "7.0.2003", "typescript": "7.0.2" + }, + "dependencies": { + "@altertable/data-app": "0.59.1" } } diff --git a/data-app/tests/server.ts b/data-app/tests/server.ts index d2f96b53..4ef72995 100644 --- a/data-app/tests/server.ts +++ b/data-app/tests/server.ts @@ -1,8 +1,8 @@ import starter from "../starter/src/index.html"; import view from "../starter/fixtures/view.html"; import components from "../starter/fixtures/index.html"; -import { createDataHandler } from "../runtime/src/server/index.ts"; -import { DataSourceError } from "../runtime/src/core/contract.ts"; +import { createDataHandler } from "@altertable/data-app/server"; +import { DataSourceError } from "@altertable/data-app/contract"; import { operations } from "../starter/src/operations.ts"; let fail = false; const handler = createDataHandler(operations, async () => ({ diff --git a/data-app/tests/starter.spec.ts b/data-app/tests/starter.spec.ts index cdbf7c97..b815ab99 100644 --- a/data-app/tests/starter.spec.ts +++ b/data-app/tests/starter.spec.ts @@ -115,7 +115,7 @@ test("Present mode retains navigation, deep links, inspection, and theme switchi page, }) => { await page.goto("/components"); - await page.getByRole("button", { name: "Present data", exact: true }).click(); + await page.getByRole("button", { name: "Present story", exact: true }).click(); await expect(page.getByRole("heading", { name: "Orders increased" })).toBeVisible(); await expect(page).toHaveURL(/present=1.*step=orders/); await page.keyboard.press("ArrowRight"); @@ -125,13 +125,14 @@ test("Present mode retains navigation, deep links, inspection, and theme switchi const dialog = page.getByRole("dialog"); await dialog.getByRole("button", { name: "Switch to dark theme" }).click(); await expect(dialog.getByRole("button", { name: "Switch to light theme" })).toBeVisible(); - await dialog.getByRole("button", { name: "Explore", exact: true }).click(); + await dialog.getByRole("button", { name: "Explore sources", exact: true }).click(); await expect(page.getByRole("tab", { name: "Glossary" })).toBeVisible(); - await expect(page.getByText("No terms for this view")).toBeVisible(); + await page.getByRole("tab", { name: "Glossary" }).click(); + await expect(page.getByRole("tabpanel", { name: "Glossary" })).toContainText( + "Completed orders grouped by customer_id.", + ); await page.getByRole("tab", { name: "Queries" }).click(); - const queryEmpty = page.getByText("No SQL for this view"); - await expect(queryEmpty).toBeVisible(); - expect(await queryEmpty.evaluate((element) => getComputedStyle(element).fontWeight)).toBe("450"); + await expect(page.getByText("SELECT 1 AS connection_check")).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.getByRole("button", { name: "Close panel" })).toHaveCount(0); await page.keyboard.press("Escape"); @@ -146,7 +147,7 @@ test("data view keeps the last result visible when refresh fails", async ({ await page.goto("/components"); const error = page.getByRole("alert"); await expect(error).toContainText("Couldn’t load results"); - await expect(error).toContainText("The lakehouse isn’t responding."); + await expect(error).toContainText("The data request failed."); await expect(error.getByRole("button", { name: "Retry" })).toBeVisible(); expect( await error.evaluate((element) => element.getBoundingClientRect().width), @@ -161,10 +162,6 @@ test("data view keeps the last result visible when refresh fails", async ({ await page.getByRole("button", { name: "Refresh data" }).click(); await expect(page.getByText("Connection view ready")).toBeVisible(); await expect(page.getByRole("alert")).toContainText(/Couldn’t refresh. Showing the last result/); - await expect(page.locator(".altertable-data-boundary-content")).toHaveAttribute( - "data-stale-error", - "true", - ); }); test("widget inspection inherits the page glossary and empty states", async ({ page }) => { @@ -176,8 +173,8 @@ test("widget inspection inherits the page glossary and empty states", async ({ p await expect(page.getByRole("dialog")).toContainText("connection-check.sql"); await expect(page.getByRole("region", { name: "Query notebook" })).toBeVisible(); await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); - await page.getByRole("button", { name: "Copy all SQL" }).click(); - await expect(page.getByRole("button", { name: "Copied all SQL" })).toBeVisible(); + await page.getByRole("button", { name: "Copy all", exact: true }).click(); + await expect(page.getByRole("button", { name: "Copied all", exact: true })).toBeVisible(); expect(await page.evaluate(() => navigator.clipboard.readText())).toContain( "-- connection-check.sql", ); @@ -358,14 +355,14 @@ test("initial loading keeps the grid shape without displaying snapshot values", await expect(loading).toHaveCount(0); }); -test("story gives its main visual more room and stacks evidence on phones", async ({ +test("authored widget layout gives its main visual more room and stacks on phones", async ({ page, }, testInfo) => { await page.goto("/components"); const story = page.getByTestId("layout-story"); - const lead = await story.locator(".altertable-story-section-lead").boundingBox(); - const visual = await story.locator(".altertable-story-section-visual").boundingBox(); - const support = await story.locator(".altertable-story-section-support").boundingBox(); + const lead = await story.getByTestId("story-lead").boundingBox(); + const visual = await story.getByTestId("story-visual").boundingBox(); + const support = await story.getByTestId("story-support").boundingBox(); expect(lead).not.toBeNull(); expect(visual).not.toBeNull(); expect(support).not.toBeNull(); From 5e9f386c66e418eff5359aa70092b4c8f8a89e69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Wed, 30 Sep 2026 16:29:26 +0200 Subject: [PATCH 2/4] refactor(app): remove vendored runtime migration Support only published data app packages. Remove checksum validation, legacy import and documentation rewrites, and migration-only consumer validation and dependency backups. Limit upgrades to package.json and bun.lock, restoring their original contents if resolution fails. Retain peer compatibility checks and avoid downgrading newer installed package versions. Update generated command references, contributor guidance, and upgrade tests to describe the package-only workflow. --- COMMANDS.md | 4 +- DEVELOPMENT.md | 2 +- cli-reference.json | 2 +- cli/src/commands/app/lib/legacy-app.ts | 176 ------------- cli/src/commands/app/lib/legacy-runtime.ts | 77 ------ cli/src/commands/app/lib/package.test.ts | 2 +- cli/src/commands/app/lib/package.ts | 6 - cli/src/commands/app/upgrade.test.ts | 272 ++++----------------- cli/src/commands/app/upgrade.ts | 124 ++-------- data-app/AGENTS.md | 2 +- data-app/README.md | 20 +- 11 files changed, 83 insertions(+), 604 deletions(-) delete mode 100644 cli/src/commands/app/lib/legacy-app.ts delete mode 100644 cli/src/commands/app/lib/legacy-runtime.ts diff --git a/COMMANDS.md b/COMMANDS.md index cb14da0f..b42fe78e 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -425,7 +425,7 @@ altertable app create|dev|build|check|upgrade - `dev` — Preview a data app locally with lakehouse access. - `build` — Typecheck and build a data app without Altertable credentials. - `check` — Validate a data app's format, lint, types, contracts, build, and client credential boundary. -- `upgrade` — Update the data app package to the CLI's tested version, migrating vendored apps. +- `upgrade` — Update the data app package to the CLI's tested version. **Examples** @@ -542,7 +542,7 @@ altertable app check --lakehouse #### `altertable app upgrade` -Update the data app package to the CLI's tested version, migrating vendored apps. +Update the data app package to the CLI's tested version. **Usage** diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 626f5ada..eb09a30e 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -231,4 +231,4 @@ When bumping the `specs/` submodule, extend the mapped tests before merge. ## Data apps -The runnable starter and package-consumer browser tests live in [`data-app/`](data-app/README.md). That guide covers starter embedding, npm package pins, legacy migration, and verification. Runtime development and releases belong to [the Data App repository](https://github.com/altertable-ai/data-app). +The runnable starter and package-consumer browser tests live in [`data-app/`](data-app/README.md). That guide covers starter embedding, npm package pins, package upgrades, and verification. Runtime development and releases belong to [the Data App repository](https://github.com/altertable-ai/data-app). diff --git a/cli-reference.json b/cli-reference.json index d00e1df9..b4f71b70 100644 --- a/cli-reference.json +++ b/cli-reference.json @@ -973,7 +973,7 @@ { "id": "altertable-app-upgrade", "command": "altertable app upgrade", - "description": "Update the data app package to the CLI's tested version, migrating vendored apps.", + "description": "Update the data app package to the CLI's tested version.", "usage": [ "altertable app upgrade [options]" ], diff --git a/cli/src/commands/app/lib/legacy-app.ts b/cli/src/commands/app/lib/legacy-app.ts deleted file mode 100644 index b393783e..00000000 --- a/cli/src/commands/app/lib/legacy-app.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { lstat, readdir, readFile } from "node:fs/promises"; -import { dirname, join, relative, resolve } from "node:path"; -import { ConfigurationError } from "@/lib/errors.ts"; - -/** Only rewrite public import paths and documentation links; preserve app-owned logic. */ -export async function legacyAppEdits(directory: string): Promise> { - const edits = new Map(); - const sources: string[] = []; - async function visit(path: string): Promise { - for (const entry of await readdir(join(directory, path), { withFileTypes: true })) { - const name = join(path, entry.name); - if (entry.isSymbolicLink()) { - throw new ConfigurationError( - `Cannot migrate symlink ${name}. Update its package imports manually before upgrading.`, - ); - } - if (entry.isDirectory()) await visit(name); - else if (entry.isFile() && /\.[cm]?[jt]sx?$/.test(name)) sources.push(name); - } - } - await visit("src"); - for (const name of sources) { - const source = await readFile(join(directory, name), "utf8"); - const removed = [ - "StorySection", - "PlayStory", - "StorySectionProps", - "PlayStoryProps", - "StoryStep", - ]; - const reactImports = [ - ...source.matchAll( - /(?:import|export)\s+(?:type\s+)?\{([^}]+)\}\s+from\s+["']@altertable\/data-app\/react["']/g, - ), - ]; - if ( - reactImports.some((match) => - match[1]!.split(",").some((binding) => - removed.includes( - binding - .trim() - .replace(/^type\s+/, "") - .split(/\s+/)[0]!, - ), - ), - ) || - /\bstory\s*=\s*\{\s*\{/.test(source) || - /\.storyStep\s*\(/.test(source) - ) { - throw new ConfigurationError( - `Migrate story authoring in ${name} before upgrading: StorySection → DataWidget, PlayStory → PresentStory, context.storyStep → context.finding, and DataApp.story → a snapshot function with explicit evidence. See https://github.com/altertable-ai/data-app/blob/main/docs/react.md#migration-from-the-earlier-runtime. No app files were changed.`, - ); - } - const next = source.replace( - /^(\s*)(import|export)\s+(type\s+)?\{([^}]+)\}\s+from\s+(["'])@altertable\/data-app\/server\5[ \t]*;?[ \t]*(\/\/[^\n]*|\/\*[^\n]*\*\/)?/gm, - ( - statement, - indent: string, - keyword: string, - type: string | undefined, - bindings: string, - quote: string, - comment: string | undefined, - ) => { - if (type) return statement; - const entries = bindings - .split(",") - .map((value) => value.trim()) - .filter(Boolean); - const local = entries.filter((value) => - /^(?:serveLocalApp|localLakehouse)(?:\s|$)/.test(value), - ); - if (!local.length) return statement; - const portable = entries.filter((value) => !local.includes(value)); - const statements = [ - `${keyword} { ${local.join(", ")} } from ${quote}@altertable/data-app/server/bun${quote};`, - ]; - if (portable.length) - statements.unshift( - `${keyword} { ${portable.join(", ")} } from ${quote}@altertable/data-app/server${quote};`, - ); - const note = comment && comment.trimStart(); - if (note && portable.length) return `${indent}${note}\n${statements.join("\n")}`; - return indent + statements.join("\n") + (note ? ` ${note}` : ""); - }, - ); - if ( - /(?:import|export)\s+\*[^;]*["']@altertable\/data-app\/server["']|import\s*\(\s*["']@altertable\/data-app\/server["']/.test( - next, - ) - ) { - throw new ConfigurationError( - `Cannot safely migrate server imports in ${name}. Import serveLocalApp and localLakehouse from @altertable/data-app/server/bun, then retry.`, - ); - } - if (source !== next) edits.set(name, next); - } - const stylesheet = "@altertable/data-app/react/styles.css"; - const hasStyles = ( - await Promise.all( - sources.map(async (name) => - (edits.get(name) ?? (await readFile(join(directory, name), "utf8"))).includes(stylesheet), - ), - ) - ).some(Boolean); - if (!hasStyles) { - const html = await readFile(join(directory, "src/index.html"), "utf8"); - const script = [...html.matchAll(/]*)>/gi)].find((match) => - /\btype\s*=\s*["']module["']/i.test(match[1]!), - ); - const reference = script?.[1]?.match(/\bsrc\s*=\s*["']([^"']+)["']/i)?.[1]; - const path = reference && relative(directory, resolve(directory, "src", reference)); - if (!path || !sources.includes(path) || !(await lstat(join(directory, path))).isFile()) { - throw new ConfigurationError( - `Cannot identify the browser entry. Import "${stylesheet}" in your browser entry, then retry app upgrade.`, - ); - } - edits.set( - path, - `import "${stylesheet}";\n${edits.get(path) ?? (await readFile(join(directory, path), "utf8"))}`, - ); - } - async function migrateDocs(path: string): Promise { - const absolute = join(directory, path); - let stat; - try { - stat = await lstat(absolute); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return; - throw error; - } - if (stat.isDirectory()) { - for (const name of await readdir(absolute)) await migrateDocs(join(path, name)); - } else if (stat.isFile() && path.endsWith(".md")) { - const source = await readFile(absolute, "utf8"); - const prefix = relative(dirname(path), "node_modules/@altertable/data-app").replaceAll( - "\\", - "/", - ); - let next = source - .replace( - /\]\((?:\.\.\/)*\.?\/?\.altertable\/runtime\/README\.md(?:#([^)]*))?\)/g, - (_, anchor: string | undefined) => { - const doc = - anchor === "execute-named-queries" - ? "contract.md" - : anchor === "entry-points" || !anchor - ? "app-authoring.md" - : "react.md"; - return `](${prefix}/docs/${doc}${anchor && doc === "react.md" ? `#${anchor}` : ""})`; - }, - ) - .replace( - /\]\((?:\.\.\/)*\.?\/?\.altertable\/runtime\/src\/server\/index\.ts\)/g, - `](${prefix}/docs/server.md)`, - ); - next = next - .replace("Read `.altertable/runtime/`", "Read the installed package docs") - .replace( - "CLI runtime contributors can use `altertable app dev --watch-runtime` to upgrade the generated runtime and restart the preview when runtime source changes. The watcher stops if a generated runtime file was edited.", - "Runtime development belongs to the standalone Data App package repository.", - ) - .replace( - "Commit `.altertable/runtime/`, including its integrity record, together with `package.json` and `bun.lock`. It is a local package required by a fresh clone. Keep custom code in `src/` and use `altertable app upgrade` to replace the managed runtime.", - "Commit app source, package.json, and bun.lock. Install the published package with bun install --frozen-lockfile. Keep custom code in src/ and use altertable app upgrade to update the tested package pin.", - ); - if (path === "AGENTS.md" && !next.includes("node_modules/@altertable/data-app/AGENTS.md")) { - next += - "\nRead `node_modules/@altertable/data-app/AGENTS.md` and its `docs/app-authoring.md` for public package usage. Do not edit installed package files.\n"; - } - if (source !== next) edits.set(path, next); - } - } - for (const path of ["AGENTS.md", "README.md", "docs"]) await migrateDocs(path); - return edits; -} diff --git a/cli/src/commands/app/lib/legacy-runtime.ts b/cli/src/commands/app/lib/legacy-runtime.ts deleted file mode 100644 index 1dbef6cd..00000000 --- a/cli/src/commands/app/lib/legacy-runtime.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { createHash } from "node:crypto"; -import { lstat, readdir, readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { ConfigurationError } from "@/lib/errors.ts"; -import { legacyRuntimePath } from "@/commands/app/lib/package.ts"; -import { isRecord } from "@/lib/object.ts"; - -type RuntimeIntegrity = { version: string; sha256: Record }; -type InstalledRuntime = RuntimeIntegrity; -function hash(content: string): string { - return createHash("sha256").update(content).digest("hex"); -} - -export async function validateLegacyRuntime(directory: string): Promise { - const path = legacyRuntimePath; - let integrity: RuntimeIntegrity; - try { - integrity = JSON.parse( - await readFile(join(directory, path, "integrity.json"), "utf8"), - ) as RuntimeIntegrity; - } catch { - throw new ConfigurationError( - "Data app runtime has no integrity record. Restore it from the original generated project.", - ); - } - if ( - !isRecord(integrity) || - typeof integrity.version !== "string" || - !integrity.sha256 || - !isRecord(integrity.sha256) || - !Object.keys(integrity.sha256).length - ) { - throw new ConfigurationError("Data app runtime has an invalid integrity record."); - } - for (const [name, checksum] of Object.entries(integrity.sha256)) { - if ( - name.includes("\\") || - name.split("/").some((part) => !part || part === "." || part === "..") || - typeof checksum !== "string" || - !/^[a-f0-9]{64}$/.test(checksum) - ) { - throw new ConfigurationError("Data app runtime has an invalid integrity record."); - } - let content: string; - try { - content = await readFile(join(directory, path, name), "utf8"); - } catch { - throw new ConfigurationError(`Data app runtime is missing ${name}.`); - } - if (hash(content) !== checksum) { - throw new ConfigurationError( - `Data app runtime ${name} was modified. Keep app code outside ${path}/.`, - ); - } - } - async function visit(relative: string): Promise { - const target = join(directory, path, relative); - const stat = await lstat(target); - if (stat.isSymbolicLink()) - throw new ConfigurationError( - `Data app runtime ${relative || path} is a symlink. Restore the managed runtime before upgrading.`, - ); - if (stat.isDirectory()) { - for (const name of await readdir(target)) - await visit(relative ? `${relative}/${name}` : name); - } else if ( - !stat.isFile() || - (relative !== "integrity.json" && !Object.hasOwn(integrity.sha256, relative)) - ) { - throw new ConfigurationError( - `Data app runtime contains unmanaged file ${relative}. Move custom files outside ${path}/ before upgrading.`, - ); - } - } - await visit(""); - return integrity; -} diff --git a/cli/src/commands/app/lib/package.test.ts b/cli/src/commands/app/lib/package.test.ts index af8ab03d..da177772 100644 --- a/cli/src/commands/app/lib/package.test.ts +++ b/cli/src/commands/app/lib/package.test.ts @@ -28,7 +28,7 @@ async function fixture(reference: string, lockedReference = reference, version = } test.each([ - ["file:.altertable/runtime", "app upgrade"], + ["file:.altertable/runtime", "published"], ["file:../local-runtime", "published"], ["github:altertable-ai/data-app", "published"], ])("check rejects %s before installing or modifying the lockfile", async (reference, message) => { diff --git a/cli/src/commands/app/lib/package.ts b/cli/src/commands/app/lib/package.ts index 388fbe23..41e20380 100644 --- a/cli/src/commands/app/lib/package.ts +++ b/cli/src/commands/app/lib/package.ts @@ -5,7 +5,6 @@ import { isRecord } from "@/lib/object.ts"; import { dataAppPayload } from "@/commands/app/lib/payload.ts"; export const dataAppPackage = "@altertable/data-app"; -export const legacyRuntimePath = ".altertable/runtime"; type AppPackage = { dependencies?: Record; @@ -40,11 +39,6 @@ export async function readAppPackage( export async function requirePublishedDataApp(directory: string): Promise { const { manifest } = await readAppPackage(directory); - if (manifest.dependencies?.[dataAppPackage] === `file:${legacyRuntimePath}`) { - throw new ConfigurationError( - "This app uses a vendored runtime. Run `altertable app upgrade` to migrate it to the published package.", - ); - } const reference = manifest.dependencies?.[dataAppPackage]; if (!reference || !/^[\d~^<>=*]/.test(reference)) { throw new ConfigurationError( diff --git a/cli/src/commands/app/upgrade.test.ts b/cli/src/commands/app/upgrade.test.ts index 280f43f6..5b66a468 100644 --- a/cli/src/commands/app/upgrade.test.ts +++ b/cli/src/commands/app/upgrade.test.ts @@ -1,12 +1,10 @@ import { afterEach, expect, test } from "bun:test"; -import { createHash } from "node:crypto"; -import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { createAppFiles, readDataAppPayload } from "@/commands/app/lib/distribution.ts"; import { dataAppPackage, - legacyRuntimePath, readAppLock, recommendedDataAppVersion, } from "@/commands/app/lib/package.ts"; @@ -19,54 +17,14 @@ afterEach(async () => { ); }); -async function fixture(legacy = true) { +async function fixture() { const directory = await mkdtemp(join(tmpdir(), "package-upgrade-")); directories.push(directory); - const payload = await readDataAppPayload(); - const files = createAppFiles(payload, { + const files = createAppFiles(await readDataAppPayload(), { name: "upgrade-test", title: "Test", scope: { organization: "Test", environment: "test" }, }); - if (legacy) { - const pkg = JSON.parse(files["package.json"]!); - pkg.dependencies[dataAppPackage] = `file:${legacyRuntimePath}`; - pkg.scripts.custom = "echo user-owned"; - files["package.json"] = JSON.stringify(pkg); - const lock = readAppLock(files["bun.lock"]!, directory); - files["bun.lock"] = JSON.stringify({ - lockfileVersion: 2, - configVersion: 1, - workspaces: { "": { dependencies: pkg.dependencies } }, - packages: { - ...lock.packages, - [dataAppPackage]: [`${dataAppPackage}@file:${legacyRuntimePath}`, {}], - }, - }); - files["src/server.ts"] = files["src/server.ts"]!.replace("/server/bun", "/server"); - files["src/main.tsx"] = files["src/main.tsx"]!.replace( - 'import "@altertable/data-app/react/styles.css";\n', - "", - ); - files["AGENTS.md"] = "[Runtime map](.altertable/runtime/README.md#find-ui-by-task)\n"; - files["docs/data.md"] = - "[Named queries](../.altertable/runtime/README.md#execute-named-queries)\n"; - const runtime = { - "package.json": JSON.stringify({ name: dataAppPackage, version: "0.57.0" }), - "src/server.ts": "export const oldRuntime = true;\n", - }; - for (const [name, content] of Object.entries(runtime)) - files[`${legacyRuntimePath}/${name}`] = content; - files[`${legacyRuntimePath}/integrity.json`] = JSON.stringify({ - version: "0.57.0", - sha256: Object.fromEntries( - Object.entries(runtime).map(([name, content]) => [ - name, - createHash("sha256").update(content).digest("hex"), - ]), - ), - }); - } for (const [name, content] of Object.entries(files)) { await mkdir(dirname(join(directory, name)), { recursive: true }); await writeFile(join(directory, name), content); @@ -74,70 +32,55 @@ async function fixture(legacy = true) { return directory; } -const tracked = [ - "package.json", - "bun.lock", - "src/server.ts", - "src/main.tsx", - "src/operations.ts", - "AGENTS.md", - "docs/data.md", - `${legacyRuntimePath}/integrity.json`, - `${legacyRuntimePath}/src/server.ts`, -]; async function snapshot(directory: string) { return Promise.all( - tracked.map(async (name) => [ - name, - await readFile(join(directory, name), "utf8").catch(() => null), - ]), + ["package.json", "bun.lock", "src/App.tsx"].map((name) => + readFile(join(directory, name), "utf8"), + ), ); } -const validateMigration = async () => {}; + async function expectUpgradeFailure(operation: () => Promise, message: string) { const failure = await operation().catch((error: unknown) => error); expect(failure).toMatchObject({ message: expect.stringContaining(message) }); } +async function updateManifest( + directory: string, + edit: (pkg: { dependencies: Record; scripts: Record }) => void, +) { + const pkg = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); + edit(pkg); + await writeFile(join(directory, "package.json"), JSON.stringify(pkg)); + const { packages } = readAppLock(await readFile(join(directory, "bun.lock"), "utf8"), directory); + await writeFile( + join(directory, "bun.lock"), + JSON.stringify({ + lockfileVersion: 2, + configVersion: 1, + workspaces: { "": { dependencies: pkg.dependencies } }, + packages, + }), + ); +} + async function resolveLock(directory: string) { const payload = await readDataAppPayload(); await writeFile(join(directory, "bun.lock"), payload.starter["bun.lock"]!); } -test("legacy migration installs the published artifact with a frozen lock and preserves app logic", async () => { +test("an exact current pin is a no-op", async () => { const directory = await fixture(); - const original = await readFile(join(directory, "src/operations.ts"), "utf8"); - expect(await upgradeApp(directory)).toBe(true); - expect(await Bun.file(join(directory, legacyRuntimePath, "package.json")).exists()).toBe(false); - expect(await readFile(join(directory, "src/operations.ts"), "utf8")).toBe(original); - expect(await readFile(join(directory, "src/server.ts"), "utf8")).toContain("/server/bun"); - expect(await readFile(join(directory, "src/main.tsx"), "utf8")).toContain("/react/styles.css"); - expect(await readFile(join(directory, "AGENTS.md"), "utf8")).toContain( - "node_modules/@altertable/data-app/docs/react.md", - ); - expect(await readFile(join(directory, "docs/data.md"), "utf8")).toContain( - "../node_modules/@altertable/data-app/docs/contract.md", - ); - expect(JSON.parse(await readFile(join(directory, "package.json"), "utf8"))).toMatchObject({ - dependencies: { [dataAppPackage]: recommendedDataAppVersion() }, - scripts: { custom: "echo user-owned" }, - }); - const child = Bun.spawn([process.execPath, "install", "--frozen-lockfile", "--ignore-scripts"], { - cwd: directory, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, code] = await Promise.all([ - new Response(child.stdout).text(), - new Response(child.stderr).text(), - child.exited, - ]); - expect(code, `${stdout}\n${stderr}`).toBe(0); + const before = await snapshot(directory); expect(await upgradeApp(directory)).toBe(false); -}, 60_000); + expect(await snapshot(directory)).toEqual(before); +}); -test("failed resolution restores all migration edits and leaves the legacy runtime intact", async () => { +test("failed resolution restores the manifest and lockfile", async () => { const directory = await fixture(); + await updateManifest(directory, (pkg) => { + pkg.dependencies[dataAppPackage] = "^0.59.1"; + }); const before = await snapshot(directory); await expectUpgradeFailure( () => @@ -152,96 +95,36 @@ test("failed resolution restores all migration edits and leaves the legacy runti expect(await snapshot(directory)).toEqual(before); }); -test("failure after runtime removal restores the runtime, manifest, lock and sources", async () => { +test("a stale resolved lockfile restores the manifest and lockfile", async () => { const directory = await fixture(); + await updateManifest(directory, (pkg) => { + pkg.dependencies[dataAppPackage] = "^0.59.1"; + }); const before = await snapshot(directory); await expectUpgradeFailure( - () => - upgradeApp(directory, { - resolveLock, - validateMigration, - afterApply(path) { - if (path === join(directory, legacyRuntimePath)) throw new Error("write failure"); - }, - }), - "write failure", - ); - expect(await snapshot(directory)).toEqual(before); -}); - -test.each(["modified", "added", "symlink"])( - "legacy migration refuses %s runtime files before mutation", - async (kind) => { - const directory = await fixture(); - const runtime = join(directory, legacyRuntimePath); - if (kind === "modified") await writeFile(join(runtime, "src/server.ts"), "custom runtime"); - else if (kind === "added") await writeFile(join(runtime, "custom.ts"), "custom runtime"); - else await symlink(join(directory, "package.json"), join(runtime, "custom.ts")); - const before = await snapshot(directory); - await expectUpgradeFailure( - () => upgradeApp(directory, { resolveLock, validateMigration }), - kind === "modified" ? "was modified" : kind === "added" ? "unmanaged file" : "symlink", - ); - expect(await snapshot(directory)).toEqual(before); - }, -); - -test("mixed and aliased server imports migrate without changing portable handler imports", async () => { - const directory = await fixture(); - await writeFile( - join(directory, "src/server.ts"), - 'import { createDataHandler, serveLocalApp as serve, localLakehouse } from "@altertable/data-app/server"; // Portable handler and local adapter\nconsole.log(createDataHandler, serve, localLakehouse);\n', - ); - await upgradeApp(directory, { resolveLock, validateMigration }); - const source = await readFile(join(directory, "src/server.ts"), "utf8"); - expect(source).toContain('import { createDataHandler } from "@altertable/data-app/server"'); - expect(source).toContain( - 'import { serveLocalApp as serve, localLakehouse } from "@altertable/data-app/server/bun"', - ); - expect(source).toContain("// Portable handler and local adapter\nimport { createDataHandler }"); - expect(source).toContain("console.log(createDataHandler, serve, localLakehouse)"); -}); - -test("unsupported namespace imports stop before any writes", async () => { - const directory = await fixture(); - await writeFile( - join(directory, "src/server.ts"), - 'import * as server from "@altertable/data-app/server";\nserver.serveLocalApp({});\n', - ); - const before = await snapshot(directory); - await expectUpgradeFailure( - () => upgradeApp(directory, { resolveLock, validateMigration }), - "Cannot safely migrate server imports", + () => upgradeApp(directory, { resolveLock: async () => {} }), + "matching package.json", ); expect(await snapshot(directory)).toEqual(before); }); -test("incompatible peers and invalid locks fail before mutation", async () => { +test("incompatible peers fail before mutation", async () => { const directory = await fixture(); - const pkg = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); - pkg.dependencies.react = "^18.0.0"; - await writeFile(join(directory, "package.json"), JSON.stringify(pkg)); + await updateManifest(directory, (pkg) => { + pkg.dependencies.react = "^18.0.0"; + }); const before = await snapshot(directory); - await expectUpgradeFailure( - () => upgradeApp(directory, { resolveLock, validateMigration }), - "Data app package needs react@", - ); + await expectUpgradeFailure(() => upgradeApp(directory), "Data app package needs react@"); expect(await snapshot(directory)).toEqual(before); - await writeFile(join(directory, "bun.lock"), "invalid"); - const invalid = await snapshot(directory); - await expectUpgradeFailure( - () => upgradeApp(directory, { resolveLock, validateMigration }), - "valid bun.lock", - ); - expect(await snapshot(directory)).toEqual(invalid); }); test("missing peers use starter defaults while preserving custom scripts", async () => { const directory = await fixture(); - const pkg = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); - delete pkg.dependencies["react-dom"]; - await writeFile(join(directory, "package.json"), JSON.stringify(pkg)); - await upgradeApp(directory, { resolveLock, validateMigration }); + await updateManifest(directory, (pkg) => { + delete pkg.dependencies["react-dom"]; + pkg.scripts.custom = "echo user-owned"; + }); + await upgradeApp(directory, { resolveLock }); expect(JSON.parse(await readFile(join(directory, "package.json"), "utf8"))).toMatchObject({ dependencies: { "react-dom": "19.3.0" }, scripts: { custom: "echo user-owned" }, @@ -249,7 +132,7 @@ test("missing peers use starter defaults while preserving custom scripts", async }); test("registry upgrades pin a compatible range and preserve unrelated dependencies and app files", async () => { - const directory = await fixture(false); + const directory = await fixture(); const pkg = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); pkg.dependencies[dataAppPackage] = "^0.59.1"; pkg.dependencies.yaml = "2.9.0"; @@ -276,7 +159,7 @@ test("registry upgrades pin a compatible range and preserve unrelated dependenci }, 60_000); test("a newer installed package is never downgraded", async () => { - const directory = await fixture(false); + const directory = await fixture(); const pkg = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); pkg.dependencies[dataAppPackage] = "9.0.0"; await writeFile(join(directory, "package.json"), JSON.stringify(pkg)); @@ -295,54 +178,3 @@ test("a newer installed package is never downgraded", async () => { expect(await upgradeApp(directory)).toBe(false); expect(await snapshot(directory)).toEqual(before); }); - -test.each([ - 'import { StorySection } from "@altertable/data-app/react";', - 'import { PlayStory as Present } from "@altertable/data-app/react";', - "export const view = ;", - "export const step = context.storyStep({});", -])("legacy story APIs receive migration guidance before mutation: %s", async (source) => { - const directory = await fixture(); - await writeFile(join(directory, "src/App.tsx"), source); - const before = await snapshot(directory); - await expectUpgradeFailure( - () => upgradeApp(directory, { resolveLock, validateMigration }), - "Migrate story authoring", - ); - expect(await readFile(join(directory, "src/App.tsx"), "utf8")).toBe(source); - expect(await snapshot(directory)).toEqual(before); -}); - -test("an incompatible namespace component rolls back after actual consumer validation", async () => { - const directory = await fixture(); - await writeFile( - join(directory, "src/custom.tsx"), - 'import * as UI from "@altertable/data-app/react"; export function Custom() { return Chart

} />; }\n', - ); - const install = Bun.spawn([process.execPath, "install", "--ignore-scripts"], { - cwd: directory, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, code] = await Promise.all([ - new Response(install.stdout).text(), - new Response(install.stderr).text(), - install.exited, - ]); - expect(code, `${stdout}\n${stderr}`).toBe(0); - await writeFile(join(directory, "node_modules/custom-marker.txt"), "original install"); - const installedPackage = await readFile( - join(directory, "node_modules/@altertable/data-app/package.json"), - "utf8", - ); - const before = await snapshot(directory); - await expectUpgradeFailure(() => upgradeApp(directory), "The app needs source changes"); - expect(await readFile(join(directory, "node_modules/custom-marker.txt"), "utf8")).toBe( - "original install", - ); - expect( - await readFile(join(directory, "node_modules/@altertable/data-app/package.json"), "utf8"), - ).toBe(installedPackage); - expect(await snapshot(directory)).toEqual(before); - expect(await readFile(join(directory, "src/custom.tsx"), "utf8")).toContain("UI.StorySection"); -}, 60_000); diff --git a/cli/src/commands/app/upgrade.ts b/cli/src/commands/app/upgrade.ts index 641445b1..9fef1874 100644 --- a/cli/src/commands/app/upgrade.ts +++ b/cli/src/commands/app/upgrade.ts @@ -1,16 +1,12 @@ -import { access, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { defineCommand } from "@/lib/command.ts"; import { ConfigurationError } from "@/lib/errors.ts"; import { dataAppPayload } from "@/commands/app/lib/payload.ts"; +import { appDirectory } from "@/commands/app/lib/run.ts"; import { copyProcessEnv } from "@/lib/env.ts"; -import { appDirectory, requireAppScripts, runAppCommand } from "@/commands/app/lib/run.ts"; -import { checkAppProject } from "@/commands/app/check.ts"; -import { validateLegacyRuntime } from "@/commands/app/lib/legacy-runtime.ts"; -import { legacyAppEdits } from "@/commands/app/lib/legacy-app.ts"; import { dataAppPackage, - legacyRuntimePath, lockedVersion, readAppLock, readAppPackage, @@ -21,8 +17,7 @@ import { export const appUpgradeCommand = defineCommand({ metadata: { name: "upgrade", - description: - "Update the data app package to the CLI's tested version, migrating vendored apps.", + description: "Update the data app package to the CLI's tested version.", examples: ["altertable app upgrade", "altertable app upgrade --dir ./my-app"], }, args: { dir: { type: "string", description: "App directory (default: current directory)." } }, @@ -57,24 +52,20 @@ export const appUpgradeCommand = defineCommand({ }); type UpgradeOptions = { - afterApply?: (path: string) => void | Promise; resolveLock?: (directory: string) => Promise; - validateMigration?: (directory: string) => Promise; }; -type Applied = { destination: string; backup: string | null }; -/** Manifest, lockfile, migration edits, and runtime removal share one rollback boundary. */ +/** Update only the package manifest and lockfile; restore both if resolution fails. */ export async function upgradeApp( directory: string, options: UpgradeOptions = {}, ): Promise { const version = recommendedDataAppVersion(); - const { manifest } = await readAppPackage(directory); + const { source: originalPackage, manifest } = await readAppPackage(directory); const reference = manifest.dependencies?.[dataAppPackage]; - const legacy = reference === `file:${legacyRuntimePath}`; - if (!reference || (!legacy && !/^[\d~^<>=*]/.test(reference))) { + if (!reference || !/^[\d~^<>=*]/.test(reference)) { throw new ConfigurationError( - `package.json must reference the published ${dataAppPackage} package or the generated vendored runtime.`, + `package.json must reference the published ${dataAppPackage} package.`, ); } let lock: string; @@ -85,14 +76,13 @@ export async function upgradeApp( } const { packages } = readAppLock(lock, directory, reference); const installedVersion = lockedVersion(packages, dataAppPackage); - if (!legacy && (!installedVersion || !Bun.semver.satisfies(installedVersion, reference))) { + if (!installedVersion || !Bun.semver.satisfies(installedVersion, reference)) { throw new ConfigurationError( "bun.lock does not resolve the declared data app package. Run bun install before upgrading.", ); } - if (legacy) await validateLegacyRuntime(directory); // A newer package installed intentionally must never be downgraded by an older CLI. - if (!legacy && installedVersion && Bun.semver.order(installedVersion, version) > 0) return false; + if (Bun.semver.order(installedVersion, version) > 0) return false; const defaults = JSON.parse(dataAppPayload.starter["package.json"]!).dependencies as Record< string, string @@ -118,34 +108,11 @@ export async function upgradeApp( manifest.dependencies![name] = fallback; packageChanged = true; } - if (!legacy && !packageChanged) return false; - const edits = legacy ? await legacyAppEdits(directory) : new Map(); + if (!packageChanged) return false; manifest.dependencies![dataAppPackage] = version; const nextPackage = `${JSON.stringify(manifest, null, 2)}\n`; - edits.set("package.json", nextPackage); - edits.set("bun.lock", lock); - const managedDirectory = join(directory, ".altertable"); - if ((await exists(managedDirectory)) && !(await lstat(managedDirectory)).isDirectory()) { - throw new ConfigurationError(".altertable must be a real directory before upgrading."); - } - await mkdir(managedDirectory, { recursive: true }); - const transaction = await mkdtemp(join(directory, ".altertable", ".upgrade-")); - const applied: Applied[] = []; - let preserveBackup = false; - async function apply(name: string, content?: string): Promise { - const destination = join(directory, name); - const backup = join(transaction, "backup", name); - const hadOriginal = await exists(destination); - if (hadOriginal) { - await mkdir(dirname(backup), { recursive: true }); - await rename(destination, backup); - } - applied.push({ destination, backup: hadOriginal ? backup : null }); - if (content !== undefined) await writeFile(destination, content); - await options.afterApply?.(destination); - } try { - for (const [name, content] of edits) await apply(name, content); + await writeFile(join(directory, "package.json"), nextPackage); await (options.resolveLock ?? resolveLockfile)(directory); const resolved = readAppLock( await readFile(join(directory, "bun.lock"), "utf8"), @@ -165,44 +132,21 @@ export async function upgradeApp( } // Bun may rewrite the root manifest; preserve all unrelated app-owned fields. await writeFile(join(directory, "package.json"), nextPackage); - if (legacy) { - // Retain the original installation until the migrated consumer passes validation. - await apply("node_modules"); - await (options.validateMigration ?? validateMigration)(directory); - await apply(legacyRuntimePath); - } } catch (error) { - const rollbackFailures: string[] = []; - for (const replacement of applied.reverse()) { - try { - await rm(replacement.destination, { recursive: true, force: true }); - if (replacement.backup) await rename(replacement.backup, replacement.destination); - } catch { - rollbackFailures.push(replacement.destination); - } - } - if (rollbackFailures.length) { - preserveBackup = true; + const restored = await Promise.allSettled([ + writeFile(join(directory, "package.json"), originalPackage), + writeFile(join(directory, "bun.lock"), lock), + ]); + if (restored.some((result) => result.status === "rejected")) throw new ConfigurationError( - `Upgrade failed and rollback was incomplete. Backups remain at ${transaction}.`, + "Upgrade failed and could not restore package.json and bun.lock.", + { cause: error }, ); - } throw error; - } finally { - if (!preserveBackup) await rm(transaction, { recursive: true, force: true }); } return true; } -async function exists(path: string): Promise { - try { - await access(path); - return true; - } catch { - return false; - } -} - async function resolveLockfile(directory: string): Promise { const env = copyProcessEnv(); for (const key of Object.keys(env)) if (key.startsWith("ALTERTABLE_")) delete env[key]; @@ -222,34 +166,6 @@ async function resolveLockfile(directory: string): Promise { ]); if (code !== 0) throw new ConfigurationError( - `Could not resolve runtime dependencies; upgrade rolled back.\n${stderr || stdout}`, + `Could not resolve data app dependencies; upgrade rolled back.\n${stderr || stdout}`, ); } - -/** Prove authored code can consume the new exports before deleting its runtime copy. */ -async function validateMigration(directory: string): Promise { - requireAppScripts(directory, ["typecheck"]); - const env = copyProcessEnv(); - for (const key of Object.keys(env)) if (key.startsWith("ALTERTABLE_")) delete env[key]; - const child = Bun.spawn([process.execPath, "install", "--frozen-lockfile", "--ignore-scripts"], { - cwd: directory, - env: { ...env, BUN_BE_BUN: "1" }, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, code] = await Promise.all([ - new Response(child.stdout).text(), - new Response(child.stderr).text(), - child.exited, - ]); - if (code !== 0) - throw new ConfigurationError( - `Could not install the migrated app; upgrade rolled back.\n${stderr || stdout}`, - ); - if ((await runAppCommand("typecheck", directory)) !== 0) { - throw new ConfigurationError( - "The app needs source changes for the published package; upgrade rolled back. See https://github.com/altertable-ai/data-app/blob/main/docs/react.md#migration-from-the-earlier-runtime.", - ); - } - await checkAppProject(directory); -} diff --git a/data-app/AGENTS.md b/data-app/AGENTS.md index 0cd1e3b4..3cfe60bf 100644 --- a/data-app/AGENTS.md +++ b/data-app/AGENTS.md @@ -4,7 +4,7 @@ | --- | --- | --- | | Package contracts, transport, or UI | [Data App repository](https://github.com/altertable-ai/data-app) | Package repository checks | | Generated app defaults or author guidance | `starter/`; [app authoring router](starter/AGENTS.md) | Starter checks and CLI creation tests | -| Scaffold distribution or package upgrades | `../cli/src/commands/app/lib/distribution.ts`, `../cli/src/commands/app/upgrade.ts` | Distribution, migration, and packaged-app tests | +| Scaffold distribution or package upgrades | `../cli/src/commands/app/lib/distribution.ts`, `../cli/src/commands/app/upgrade.ts` | Distribution, upgrade, and packaged-app tests | | Consumer integration or browser behavior | `tests/`, `starter/fixtures/` | Browser tests against the published package | See [development commands](README.md#develop). Keep SQL and metric definitions in app-owned source. diff --git a/data-app/README.md b/data-app/README.md index e7e1dc03..e2687f4d 100644 --- a/data-app/README.md +++ b/data-app/README.md @@ -40,19 +40,9 @@ source watcher. `app upgrade` updates the app manifest and lockfile to the CLI's tested package version, preserving unrelated dependencies and app code. A newer installed package is not downgraded. Peer incompatibilities stop with an actionable error; missing peers are seeded from starter defaults. -Registry upgrades resolve only the lockfile. Legacy migrations also install the frozen dependencies -with lifecycle scripts disabled and run the app typecheck and contract/browser-boundary checks before -removing the managed runtime. Failures restore -the manifest, lockfile, migration edits, and the original installed dependencies. Restart running -previews after an upgrade. - -For older generated apps using `file:.altertable/runtime`, upgrade first validates all managed -checksums and refuses modified, added, or symlinked runtime files. It migrates local server helpers -to `/server/bun`, imports the public stylesheet, updates vendored documentation links, and removes -the runtime copy only after package resolution succeeds. Unsupported custom server imports or a -custom browser entry need the indicated manual edit before retrying. Older custom stories must -migrate from `StorySection`/`PlayStory` to `DataWidget`/`PresentStory` and explicit finding evidence -before upgrading; see the [package migration guide](https://github.com/altertable-ai/data-app/blob/main/docs/react.md#migration-from-the-earlier-runtime). +Upgrades resolve only the lockfile and restore the original manifest and lockfile if resolution +fails. Restart running previews after an upgrade. Apps must already use the published package; +`app upgrade` does not migrate local runtime copies or rewrite app source. ## Verify @@ -66,7 +56,7 @@ bun run --cwd cli data-app:test:browser Install Playwright Chromium with `bunx playwright install chromium` from `data-app/tests/` if needed. Browser checks cover connection and request states, theme, context, and presentation -at phone and desktop widths. CLI tests cover offline scaffolding, npm consumption, legacy migration, -peer checks, and rollback. Release smoke checks create an app outside the checkout, install its +at phone and desktop widths. CLI tests cover offline scaffolding, npm consumption, package upgrades, +peer checks, and failed resolution. Release smoke checks create an app outside the checkout, install its frozen lockfile, and run `app check` with the packaged CLI. The minimum Bun compatibility job checks scaffolding only; app builds use the repository's current toolchain. From e5a4dead593b8744c6421fb0f5440125195d8bb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Wed, 30 Sep 2026 16:44:25 +0200 Subject: [PATCH 3/4] chore(app): update data app package to 0.61.0 Pin the generated starter and browser-test consumer to the published 0.61.0 release and regenerate both Bun lockfiles. Derive compatible ranges in upgrade tests from the starter's tested version so future release bumps do not require fixture edits. --- cli/src/commands/app/upgrade.test.ts | 6 +++--- data-app/starter/bun.lock | 4 ++-- data-app/starter/package.json | 2 +- data-app/tests/bun.lock | 4 ++-- data-app/tests/package.json | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cli/src/commands/app/upgrade.test.ts b/cli/src/commands/app/upgrade.test.ts index 5b66a468..b57d32c6 100644 --- a/cli/src/commands/app/upgrade.test.ts +++ b/cli/src/commands/app/upgrade.test.ts @@ -79,7 +79,7 @@ test("an exact current pin is a no-op", async () => { test("failed resolution restores the manifest and lockfile", async () => { const directory = await fixture(); await updateManifest(directory, (pkg) => { - pkg.dependencies[dataAppPackage] = "^0.59.1"; + pkg.dependencies[dataAppPackage] = `^${recommendedDataAppVersion()}`; }); const before = await snapshot(directory); await expectUpgradeFailure( @@ -98,7 +98,7 @@ test("failed resolution restores the manifest and lockfile", async () => { test("a stale resolved lockfile restores the manifest and lockfile", async () => { const directory = await fixture(); await updateManifest(directory, (pkg) => { - pkg.dependencies[dataAppPackage] = "^0.59.1"; + pkg.dependencies[dataAppPackage] = `^${recommendedDataAppVersion()}`; }); const before = await snapshot(directory); await expectUpgradeFailure( @@ -134,7 +134,7 @@ test("missing peers use starter defaults while preserving custom scripts", async test("registry upgrades pin a compatible range and preserve unrelated dependencies and app files", async () => { const directory = await fixture(); const pkg = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); - pkg.dependencies[dataAppPackage] = "^0.59.1"; + pkg.dependencies[dataAppPackage] = `^${recommendedDataAppVersion()}`; pkg.dependencies.yaml = "2.9.0"; pkg.scripts.custom = "echo user-owned"; await writeFile(join(directory, "package.json"), JSON.stringify(pkg)); diff --git a/data-app/starter/bun.lock b/data-app/starter/bun.lock index 3e7d0967..4c47ce3b 100644 --- a/data-app/starter/bun.lock +++ b/data-app/starter/bun.lock @@ -5,7 +5,7 @@ "": { "name": "data-app-starter", "dependencies": { - "@altertable/data-app": "0.59.1", + "@altertable/data-app": "0.61.0", "react": "19.3.0", "react-dom": "19.3.0", }, @@ -21,7 +21,7 @@ }, }, "packages": { - "@altertable/data-app": ["@altertable/data-app@0.59.1", "", { "dependencies": { "@floating-ui/react": "0.27.20", "@internationalized/date": "3.12.4", "@tanstack/react-query": "5.104.0", "fuzzysort": "4.0.2", "lucide-react": "1.48.0", "react-aria-components": "1.21.1" }, "peerDependencies": { "react": ">=19.2.0", "react-dom": ">=19.2.0" } }, "sha512-XkCXlAI32FjewiHQ9+H0bahVWG0FgfBa9SxMd0mbkOYNba/lwK9pcwaGni3dqOtTvKYNAKI1ylI+OeyJqpxwpQ=="], + "@altertable/data-app": ["@altertable/data-app@0.61.0", "", { "dependencies": { "@floating-ui/react": "0.27.20", "@internationalized/date": "3.12.4", "@tanstack/react-query": "5.104.0", "fuzzysort": "4.0.2", "lucide-react": "1.48.0", "react-aria-components": "1.21.1" }, "peerDependencies": { "react": ">=19.2.0", "react-dom": ">=19.2.0" } }, "sha512-68nAEv/G0C7ZJ6P+Y0kAEBXEG4cMvEqORJYqR1/9dd+CeEYHMJDN4v20/7VUnYnEfz6reXJGyuKq3MMv6zbhpA=="], "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], diff --git a/data-app/starter/package.json b/data-app/starter/package.json index ce2629de..f29418fb 100644 --- a/data-app/starter/package.json +++ b/data-app/starter/package.json @@ -15,7 +15,7 @@ "build": "bun build src/index.html --outdir=dist --production" }, "dependencies": { - "@altertable/data-app": "0.59.1", + "@altertable/data-app": "0.61.0", "react": "19.3.0", "react-dom": "19.3.0" }, diff --git a/data-app/tests/bun.lock b/data-app/tests/bun.lock index 54469dfa..fb858745 100644 --- a/data-app/tests/bun.lock +++ b/data-app/tests/bun.lock @@ -5,7 +5,7 @@ "": { "name": "data-app-browser-tests", "dependencies": { - "@altertable/data-app": "0.59.1", + "@altertable/data-app": "0.61.0", }, "devDependencies": { "@playwright/test": "1.63.0", @@ -16,7 +16,7 @@ }, }, "packages": { - "@altertable/data-app": ["@altertable/data-app@0.59.1", "", { "dependencies": { "@floating-ui/react": "0.27.20", "@internationalized/date": "3.12.4", "@tanstack/react-query": "5.104.0", "fuzzysort": "4.0.2", "lucide-react": "1.48.0", "react-aria-components": "1.21.1" }, "peerDependencies": { "react": ">=19.2.0", "react-dom": ">=19.2.0" } }, "sha512-XkCXlAI32FjewiHQ9+H0bahVWG0FgfBa9SxMd0mbkOYNba/lwK9pcwaGni3dqOtTvKYNAKI1ylI+OeyJqpxwpQ=="], + "@altertable/data-app": ["@altertable/data-app@0.61.0", "", { "dependencies": { "@floating-ui/react": "0.27.20", "@internationalized/date": "3.12.4", "@tanstack/react-query": "5.104.0", "fuzzysort": "4.0.2", "lucide-react": "1.48.0", "react-aria-components": "1.21.1" }, "peerDependencies": { "react": ">=19.2.0", "react-dom": ">=19.2.0" } }, "sha512-68nAEv/G0C7ZJ6P+Y0kAEBXEG4cMvEqORJYqR1/9dd+CeEYHMJDN4v20/7VUnYnEfz6reXJGyuKq3MMv6zbhpA=="], "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], diff --git a/data-app/tests/package.json b/data-app/tests/package.json index 735eb60c..2285efc0 100644 --- a/data-app/tests/package.json +++ b/data-app/tests/package.json @@ -15,6 +15,6 @@ "typescript": "7.0.2" }, "dependencies": { - "@altertable/data-app": "0.59.1" + "@altertable/data-app": "0.61.0" } } From e7a4fff790e1dd11e994448512d4894c138cc329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 1 Oct 2026 09:52:42 +0200 Subject: [PATCH 4/4] test(app): validate authoring links against the installed package The distribution link test accidentally required dependencies installed under the starter source directory, so isolated runs failed in clean checkouts even though the pinned npm artifact ships the documentation. Keep app-owned link checks in the offline distribution test. Check all generated Markdown links after the existing generated-app frozen install, and assert that its installed package matches the tested starter version. Verified both affected tests with starter node_modules temporarily absent: the original test failed, and the revised tests passed. --- cli/src/commands/app/create.test.ts | 21 +++++++++++++++++-- cli/src/commands/app/lib/distribution.test.ts | 11 ++++------ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/cli/src/commands/app/create.test.ts b/cli/src/commands/app/create.test.ts index e201b11e..98df0125 100644 --- a/cli/src/commands/app/create.test.ts +++ b/cli/src/commands/app/create.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, posix } from "node:path"; +import { readDataAppPayload } from "@/commands/app/lib/distribution.ts"; import { runCommandWithTestRuntime } from "@/test-utils/cli.ts"; import { recommendedDataAppVersion } from "@/commands/app/lib/package.ts"; import { configSet, ensureProfileExists, setActiveProfile } from "@/lib/profile-store.ts"; @@ -253,7 +254,7 @@ describe("app create", () => { }); }); - test("installs the generated app with its frozen lockfile and checks the project", async () => { + test("installs the generated app with its frozen lockfile and checks authoring links and the project", async () => { const directory = join(home, "first-check"); await runCommandWithTestRuntime([ "app", @@ -269,6 +270,22 @@ describe("app create", () => { stderr: "pipe", }); expect(install.exitCode).toBe(0); + expect( + JSON.parse( + readFileSync(join(directory, "node_modules/@altertable/data-app/package.json"), "utf8"), + ).version, + ).toBe(recommendedDataAppVersion()); + const { starter } = await readDataAppPayload(); + for (const name of Object.keys(starter)) { + if (!name.endsWith(".md")) continue; + const content = readFileSync(join(directory, name), "utf8"); + for (const match of content.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)) { + const target = match[1]!.split("#")[0]!; + if (!target || /^[a-z]+:\/\//i.test(target)) continue; + const path = posix.normalize(posix.join(posix.dirname(name), target)); + expect(existsSync(join(directory, path)), `${name} links to missing ${path}`).toBe(true); + } + } const result = await runCommandWithTestRuntime(["app", "check", "--dir", directory], { debug: false, json: false, diff --git a/cli/src/commands/app/lib/distribution.test.ts b/cli/src/commands/app/lib/distribution.test.ts index c8412f64..71997606 100644 --- a/cli/src/commands/app/lib/distribution.test.ts +++ b/cli/src/commands/app/lib/distribution.test.ts @@ -65,7 +65,7 @@ test("distribution rejects symlinks and development artifacts inside starter sou expect(readDataAppPayload(root)).rejects.toThrow("Development artifact"); }); -test("authoring links resolve to app files or published package docs", async () => { +test("app-owned authoring links resolve within the generated files", async () => { const files = createAppFiles(await readDataAppPayload(), { name: "docs-test", title: "Docs", @@ -78,12 +78,9 @@ test("authoring links resolve to app files or published package docs", async () const target = match[1]!.split("#")[0]!; if (!target || /^[a-z]+:\/\//i.test(target)) continue; const path = posix.normalize(posix.join(posix.dirname(name), target)); - if (path.startsWith("node_modules/@altertable/data-app/")) { - expect( - await Bun.file(join(dataAppDirectory, "starter", path)).exists(), - `${name} links to missing ${path}`, - ).toBe(true); - } else expect(files[path], `${name} links to missing ${path}`).toBeDefined(); + // Package documentation is validated after installation in the app creation test. + if (path.startsWith("node_modules/@altertable/data-app/")) continue; + expect(files[path], `${name} links to missing ${path}`).toBeDefined(); } } });