diff --git a/.github/workflows/local-query-board.yml b/.github/workflows/local-query-board.yml new file mode 100644 index 00000000..638fe842 --- /dev/null +++ b/.github/workflows/local-query-board.yml @@ -0,0 +1,23 @@ +name: Local query board +on: + pull_request: + paths: ['applications/local-query-board/**', '.github/workflows/local-query-board.yml'] + push: + paths: ['applications/local-query-board/**', '.github/workflows/local-query-board.yml'] +permissions: + contents: read +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: applications/local-query-board + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.4.2' + - run: bun install --frozen-lockfile --ignore-scripts + - run: bun run typecheck + - run: bun run test + - run: bun run build diff --git a/applications/local-query-board/.env.example b/applications/local-query-board/.env.example new file mode 100644 index 00000000..3ab38369 --- /dev/null +++ b/applications/local-query-board/.env.example @@ -0,0 +1,6 @@ +DATABASE_URL=postgresql://planning_app:replace-me@replace-me:5432/postgres +PG_CA_CERT_PATH=.deployment/postgres-ca.pem +APP_ORIGIN=http://127.0.0.1:3000 +ELECTRIC_URL=http://127.0.0.1:3005 +ELECTRIC_SECRET=replace-with-the-same-server-secret-as-electric +# Local database only: DATABASE_LOCAL=true and a loopback DATABASE_URL. diff --git a/applications/local-query-board/.gitignore b/applications/local-query-board/.gitignore new file mode 100644 index 00000000..cea2c5c8 --- /dev/null +++ b/applications/local-query-board/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.env +.deployment/ +.clickhouse/ +playwright-report/ +test-results/ diff --git a/applications/local-query-board/README.md b/applications/local-query-board/README.md new file mode 100644 index 00000000..bac49858 --- /dev/null +++ b/applications/local-query-board/README.md @@ -0,0 +1,174 @@ +# Fieldwork: a live planning board + +A small shared task board using **Electric, TanStack DB, Hono and Drizzle**, backed by **ClickHouse Managed Postgres**. Electric streams two scoped collections. TanStack DB joins tasks to projects and runs the status/project filters locally. Hono owns writes; the browser never writes directly to Postgres. + +![Fieldwork planning board](docs/local-query-board.png) + +_Local seeded workflow capture; Managed Postgres verification is described below._ + +Create a task in one tab and watch it arrive in another. Change its status or priority, then filter the board. Writes use an expected revision; competing updates cannot silently overwrite one another. The UI distinguishes a committed write from a stream that is still catching up. This is a trusted loopback workbench without accounts or offline writes. + +## Requirements + +Run dependencies and containers on Linux, for example inside an OrbStack VM. The verified stack uses Bun 1.4.2, Postgres 18, Electric 1.8.1, TanStack DB 0.11.3, its React adapter 0.5.3 and Electric adapter 0.5.4, Hono 4.13.13 and Drizzle 0.45.3. Install Docker with Buildx, Git, `psql`, `jq`, and [clickhousectl](https://github.com/ClickHouse/clickhousectl). Package versions are pinned in `package.json` and `bun.lock`. + +```sh +curl -fsSL https://bun.sh/install | bash -s "bun-v1.4.2" +export PATH="$HOME/.bun/bin:$HOME/.local/bin:$PATH" +curl -fsSL https://clickhouse.com/cli | sh +clickhousectl cloud auth login --interactive +clickhousectl cloud auth status +git clone https://github.com/ClickHouse/examples.git +cd examples/applications/local-query-board +bun install --frozen-lockfile +``` + +## Create the database with clickhousectl + +Interactive Cloud authentication uses your Admin API key and secret. A disposable Cloud database incurs ordinary database costs until deleted. Create your own resource, keep its credentials private and record its ID for cleanup. + +```sh +umask 077 +mkdir -p .deployment +chmod 700 .deployment +clickhousectl cloud org list +export ORG_ID=your-organization-id +clickhousectl cloud postgres create --org-id "$ORG_ID" \ + --name fieldwork-demo --provider aws --region us-east-1 \ + --size c6gd.large --pg-version 18 --ha-type none --json > .deployment/create.json +export SERVICE_ID="$(jq -r .id .deployment/create.json)" +clickhousectl cloud postgres get "$SERVICE_ID" --org-id "$ORG_ID" --json +# Wait until the get response reports running, then retrieve its CA. +clickhousectl cloud postgres certs get "$SERVICE_ID" --org-id "$ORG_ID" \ + --output .deployment/postgres-ca.pem +``` + +Use the hostname and owner username/password returned by the service. Use port 5432 and database `postgres` for this direct-service setup; the service response remains authoritative. Keep owner credentials separate from the runtime roles. + +```sh +export PGHOST=the-service-hostname PGPORT=5432 PGDATABASE=postgres +export PGSSLMODE=verify-full PGSSLROOTCERT="$PWD/.deployment/postgres-ca.pem" +export PG_OWNER_USER=the-owner-username +read -r -s PG_OWNER_PASSWORD; export PG_OWNER_PASSWORD +export PG_MIGRATION_PASSWORD="Aa1_$(openssl rand -hex 24)" +export PG_APP_PASSWORD="Aa1_$(openssl rand -hex 24)" +export PG_SYNC_PASSWORD="Aa1_$(openssl rand -hex 24)" +# Save them privately for the run instructions or an interrupted setup retry. +printf 'PG_MIGRATION_PASSWORD=%s\nPG_APP_PASSWORD=%s\nPG_SYNC_PASSWORD=%s\n' \ + "$PG_MIGRATION_PASSWORD" "$PG_APP_PASSWORD" "$PG_SYNC_PASSWORD" > .deployment/role-passwords.env +PGUSER="$PG_OWNER_USER" PGPASSWORD="$PG_OWNER_PASSWORD" \ + psql -X -v ON_ERROR_STOP=1 -f sql/roles.sql +PGUSER=planning_migration PGPASSWORD="$PG_MIGRATION_PASSWORD" \ + psql -X -v ON_ERROR_STOP=1 -f sql/schema.sql +PGUSER=planning_migration PGPASSWORD="$PG_MIGRATION_PASSWORD" \ + psql -X -v ON_ERROR_STOP=1 -f sql/seed.sql +PGUSER="$PG_OWNER_USER" PGPASSWORD="$PG_OWNER_PASSWORD" \ + psql -X -v ON_ERROR_STOP=1 -f sql/publication.sql +``` + +The migration role owns `planning`. The application role can read projects and read/insert/update tasks. The sync role has `REPLICATION`, schema usage and table `SELECT`, with no continuing database `CREATE` privilege. Manual publication includes only the two app tables, both using `REPLICA IDENTITY FULL`. Seed data includes a separate `private-lab` board to make the shape scope observable. + +## Build and start Electric + +The verified route builds the official Electric release source. During development, anonymous pulls from the documented Docker Hub repository were unavailable; no alternate mirror is required. This source build pins the exact 1.8.1 release commit and uses its original Dockerfile plus the telemetry build context. + +```sh +git clone --depth 1 --branch '@core/sync-service@1.8.1' \ + https://github.com/electric-sql/electric.git .deployment/electric-source +cd .deployment/electric-source +test "$(git rev-parse HEAD)" = 0f404200402f918a4b1596bc5c8a53479a435349 +docker build --build-arg ELECTRIC_VERSION=1.8.1 \ + --build-context electric-telemetry=packages/electric-telemetry \ + -f packages/sync-service/Dockerfile \ + -t fieldwork-electric:1.8.1 packages/sync-service +cd ../.. +``` + +Create `.deployment/electric.env` with these values, then `chmod 600` it. Replace the hostname/password and generate a secret used only by Hono and Electric. Do not prefix it with `VITE_`. + +```dotenv +DATABASE_URL=postgresql://planning_sync:replace-me@the-service-hostname:5432/postgres?sslmode=require +ELECTRIC_SECRET=replace-with-a-random-server-secret +ELECTRIC_DATABASE_CA_CERTIFICATE_FILE=/certs/postgres-ca.pem +ELECTRIC_REPLICATION_STREAM_ID=local_query_board +ELECTRIC_MANUAL_TABLE_PUBLISHING=true +ELECTRIC_DB_POOL_SIZE=3 +ELECTRIC_TELEMETRY=false +ELECTRIC_STORAGE_DIR=/var/lib/electric +``` + +Electric's URL parser supports `sslmode=require`, rather than `verify-full`. Supplying `ELECTRIC_DATABASE_CA_CERTIFICATE_FILE` enables certificate and hostname verification in its connector; do not omit the CA to bypass a connection error. + +```sh +docker run -d --name fieldwork-electric \ + --env-file .deployment/electric.env -p 127.0.0.1:3005:3000 \ + -v "$PWD/.deployment/postgres-ca.pem:/certs/postgres-ca.pem:ro" \ + -v fieldwork-electric:/var/lib/electric fieldwork-electric:1.8.1 +curl -fsS http://127.0.0.1:3005/v1/health +``` + +## Run the board + +Copy `.env.example` to `.env` and fill in the `planning_app` password, direct hostname, CA path and the same Electric secret. Keep `.env` private. The app accepts a plain Postgres URL without TLS query overrides. For the tested Bun runtime, the connector narrowly retries individual supplied CA anchors only if a rotation bundle triggers `CERT_SIGNATURE_FAILURE`; every attempt retains certificate and hostname checks. + +```sh +cp .env.example .env +chmod 600 .env +bun run typecheck +bun run build +bun run start +``` + +When using a VM, forward only the loopback app port from your laptop: + +```sh +ssh -N -L 3000:127.0.0.1:3000 YOUR_MACHINE@orb +``` + +Open `http://127.0.0.1:3000` in two tabs. The UI uses Electric's HTTP shape stream, including long polling for changes; it does not repeatedly fetch tasks from the write API. Two eager collections keep this demo small. For remote deployment, add actual authentication and server-selected per-user scopes, and use HTTPS/HTTP2 for concurrent shape streams. + +## How the pieces connect + +- `src/client/collections.ts` creates Electric-backed TanStack DB collections with Zod schemas and stable row keys. +- `src/client/main.tsx` uses `useLiveQuery` to join tasks with projects and apply local filters. There are no optimistic local inserts: a task appears when its committed row arrives through Electric. +- `src/server/proxy.ts` fixes the table, columns and `board_id = 'studio'` scope. Only supported stream continuation parameters pass through; caller-supplied tables, filters, columns, secrets and subset queries are refused. +- `src/server/writes.ts` uses Drizzle transactions. An update matches the expected revision and increments it atomically. The transaction returns `pg_current_xact_id()::xid::text`; `awaitTxId` waits for that same committed transaction in the task collection. + +A delayed stream does not undo a committed write. The input clears after the API confirms creation, and the UI says the stream is catching up if synchronization times out. A stale update returns `409` and leaves the database unchanged. + +## Verify + +```sh +bun run test +bun run test:integration +bunx playwright install --with-deps chromium +bun run test:browser +# Optional real container interruption/recovery check (stops only this named engine): +ELECTRIC_TEST_CONTAINER=fieldwork-electric bun run test:browser +``` + +The secret-free CI checks frozen installation, typecheck, build and proxy unit checks. Integration checks exercise the actual database: eight competing revisions produce one winner, invalid project/status writes leave stored state unchanged, and runtime DDL/deletion grants are refused. The browser check uses two real tabs for snapshot, CDC, local join/filter, an actual hidden-project API rejection, a simulated UI error-state control and shape override refusal. Managed verification also exercised wrong CA/hostname controls in both connectors, an actual engine interruption during a committed create, and fresh browser state after real app/engine restart. Integration/browser commands need a running seeded database and Electric/app processes. The interruption test explicitly skips unless `ELECTRIC_TEST_CONTAINER` names this owned engine; the regular two-tab test always runs. + +For local Postgres, use `clickhousectl local postgres start --name fieldwork --version 18`. Set `wal_level=logical` and restart that owned container before configuring roles/publication. The app's local mode is restricted to loopback; Electric connects over a dedicated Docker network with `sslmode=disable` and no CA only for this local fixture. + +## Cleanup + +Stop Electric before removing its replication slot; stopping the app alone leaves the sync engine active. Use the owner connection to inspect and remove only this example's publication/slot, then delete your database service. + +```sh +docker stop fieldwork-electric +PGUSER="$PG_OWNER_USER" PGPASSWORD="$PG_OWNER_PASSWORD" psql -X -v ON_ERROR_STOP=1 \ + -c "SELECT slot_name, active FROM pg_replication_slots WHERE slot_name LIKE '%local_query_board%';" +# Substitute the exact inactive slot name returned above. +PGUSER="$PG_OWNER_USER" PGPASSWORD="$PG_OWNER_PASSWORD" psql -X -v ON_ERROR_STOP=1 \ + -c "SELECT pg_drop_replication_slot('the-owned-slot-name');" +PGUSER="$PG_OWNER_USER" PGPASSWORD="$PG_OWNER_PASSWORD" psql -X -v ON_ERROR_STOP=1 \ + -c 'DROP PUBLICATION electric_publication_local_query_board;' +docker rm fieldwork-electric +docker volume rm fieldwork-electric +clickhousectl cloud postgres delete "$SERVICE_ID" --org-id "$ORG_ID" --json +clickhousectl cloud postgres list --org-id "$ORG_ID" --json +# Verify your owned ID is absent. Never delete another service by name similarity. +``` + +See the [Electric configuration](https://electric.ax/docs/sync/api/config), [Postgres permissions](https://electric.ax/docs/sync/guides/postgres-permissions), [shape authorization](https://electric.ax/docs/sync/guides/auth) and [TanStack Electric collection](https://tanstack.com/db/latest/docs/collections/electric-collection) references for deployment beyond this workbench. diff --git a/applications/local-query-board/bun.lock b/applications/local-query-board/bun.lock new file mode 100644 index 00000000..b1831196 --- /dev/null +++ b/applications/local-query-board/bun.lock @@ -0,0 +1,244 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "local-query-board", + "dependencies": { + "@electric-sql/client": "1.5.28", + "@tanstack/db": "0.11.3", + "@tanstack/electric-db-collection": "0.5.4", + "@tanstack/react-db": "0.5.3", + "drizzle-orm": "0.45.3", + "hono": "4.13.13", + "pg": "8.23.1", + "react": "19.3.0", + "react-dom": "19.3.0", + "zod": "4.6.5", + }, + "devDependencies": { + "@playwright/test": "1.63.0", + "@types/bun": "1.4.2", + "@types/pg": "8.23.1", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "6.1.1", + "typescript": "7.0.2", + "vite": "8.3.2", + }, + }, + }, + "packages": { + "@electric-sql/client": ["@electric-sql/client@1.5.28", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "^4.18.1" }, "bin": { "intent": "bin/intent.mjs" } }, "sha512-b0cym3YWXL6PbQmEF0xDRsLt2xgXX46stvOSRAIXRPAP6dLZIHZL0o0HxeTdsZ27L3PbIg6Pw6KnyanRVGtWPA=="], + + "@microsoft/fetch-event-source": ["@microsoft/fetch-event-source@2.0.1", "", {}, "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="], + + "@oxc-project/types": ["@oxc-project/types@0.152.0", "", {}, "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw=="], + + "@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="], + + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.12", "", { "os": "android", "cpu": "arm" }, "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.12", "", { "os": "android", "cpu": "arm64" }, "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.12", "", { "os": "linux", "cpu": "arm" }, "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.64.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dRBZlbt2sEtF8Mn4MODThze9fIPdDoOhVSlXMAKy5dQbOYK9pOOoPY4Fat3OTtr0+8zbJo5TTZwlsfxY/kDNJg=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@tanstack/db": ["@tanstack/db@0.11.3", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@tanstack/db-ivm": "0.1.25", "@tanstack/pacer-lite": "^0.2.1" }, "peerDependencies": { "typescript": ">=4.7" } }, "sha512-xrBwD+mIm0BU4zYm0jrhQcWorhZGwSysjCXTB+7i9B9m2LsbTSdqYRT9RhvB0D51cbteX6p959LCbyIyeZ3XUg=="], + + "@tanstack/db-ivm": ["@tanstack/db-ivm@0.1.25", "", { "dependencies": { "fractional-indexing": "^3.2.0", "sorted-btree": "^1.8.1" }, "peerDependencies": { "typescript": ">=4.7" } }, "sha512-/lLM7DHZLDurkoAGGgIAkc2Io483hXMXneaIOTrnpe7saJDU6Vz/GLT/bkvMpg6WfM3j4VJHw85fduo761CTNw=="], + + "@tanstack/electric-db-collection": ["@tanstack/electric-db-collection@0.5.4", "", { "dependencies": { "@electric-sql/client": "^1.5.15", "@standard-schema/spec": "^1.1.0", "@tanstack/db": "0.11.3", "debug": "^4.4.3" } }, "sha512-me0CEYs3dIQpCDhlTHLftnkq1Otr9eG8x4/wNFHiufI9NwXJXnHOTt+v083+88QDiIXs/dKDmVHCOpE0FWVs0A=="], + + "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.2.2", "", {}, "sha512-eQ1MyLKCHyXiH7NbdmB80W77OhiMgGBUb+qDx/8WMGbwg5Lf/NlfD0TfNYAqY77i8V3AxoDoYdICrQE5ADw4Yw=="], + + "@tanstack/react-db": ["@tanstack/react-db@0.5.3", "", { "dependencies": { "@tanstack/db": "0.11.3" }, "peerDependencies": { "react": ">=18.0.0" } }, "sha512-UVR/gsm5Q4uGi6mrLXVsu7ZgNh9MokZNd9ZBAzg286P2TaBNZvnPUA6K09FydPlIRz+M1gC7f/5o1FHCwPLrFw=="], + + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + + "@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="], + + "@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="], + + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], + + "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="], + + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "drizzle-orm": ["drizzle-orm@0.45.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@netlify/db": ">=0.4.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@netlify/db", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fractional-indexing": ["fractional-indexing@3.4.0", "", {}, "sha512-8J3glhz2rrpKG6KmI7wmJo3zH1VjeOpN+vTJSw1fOyO+Viqq3zX6/5NGh6oaZB2qIAYdOYuu5Dz9xp4faOO0Pg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "hono": ["hono@4.13.13", "", {}, "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], + + "pg": ["pg@8.23.1", "", { "dependencies": { "pg-connection-string": "^2.14.1", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.1", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ=="], + + "pg-cloudflare": ["pg-cloudflare@1.4.1", "", {}, "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ=="], + + "pg-connection-string": ["pg-connection-string@2.14.1", "", {}, "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], + + "pg-protocol": ["pg-protocol@1.16.1", "", {}, "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], + + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + + "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + + "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], + + "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], + + "rolldown": ["rolldown@1.2.12", "", { "dependencies": { "@oxc-project/types": "=0.152.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.12", "@rolldown/binding-android-arm64": "1.2.12", "@rolldown/binding-darwin-arm64": "1.2.12", "@rolldown/binding-darwin-x64": "1.2.12", "@rolldown/binding-freebsd-x64": "1.2.12", "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", "@rolldown/binding-linux-arm64-gnu": "1.2.12", "@rolldown/binding-linux-arm64-musl": "1.2.12", "@rolldown/binding-linux-ppc64-gnu": "1.2.12", "@rolldown/binding-linux-s390x-gnu": "1.2.12", "@rolldown/binding-linux-x64-gnu": "1.2.12", "@rolldown/binding-linux-x64-musl": "1.2.12", "@rolldown/binding-openharmony-arm64": "1.2.12", "@rolldown/binding-win32-arm64-msvc": "1.2.12", "@rolldown/binding-win32-x64-msvc": "1.2.12" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ=="], + + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + + "sorted-btree": ["sorted-btree@1.8.1", "", {}, "sha512-395+XIP+wqNn3USkFSrNz7G3Ss/MXlZEqesxvzCRFwL14h6e8LukDHdLBePn5pwbm5OQ9vGu8mDyz2lLDIqamQ=="], + + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "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=="], + + "vite": ["vite@8.3.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.11", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], + } +} diff --git a/applications/local-query-board/docs/local-query-board.png b/applications/local-query-board/docs/local-query-board.png new file mode 100644 index 00000000..0e9b5eb8 Binary files /dev/null and b/applications/local-query-board/docs/local-query-board.png differ diff --git a/applications/local-query-board/index.html b/applications/local-query-board/index.html new file mode 100644 index 00000000..bbb116a2 --- /dev/null +++ b/applications/local-query-board/index.html @@ -0,0 +1 @@ +Fieldwork — live planning board
diff --git a/applications/local-query-board/package.json b/applications/local-query-board/package.json new file mode 100644 index 00000000..2705aa8a --- /dev/null +++ b/applications/local-query-board/package.json @@ -0,0 +1,35 @@ +{ + "name": "local-query-board", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "build": "vite build", + "start": "bun src/server/index.ts", + "test": "bun test tests/proxy.test.ts", + "test:integration": "bun test tests/database.test.ts", + "test:browser": "playwright test" + }, + "dependencies": { + "drizzle-orm": "0.45.3", + "pg": "8.23.1", + "react": "19.3.0", + "react-dom": "19.3.0", + "@tanstack/db": "0.11.3", + "@tanstack/react-db": "0.5.3", + "@tanstack/electric-db-collection": "0.5.4", + "@electric-sql/client": "1.5.28", + "hono": "4.13.13", + "zod": "4.6.5" + }, + "devDependencies": { + "@types/pg": "8.23.1", + "@types/bun": "1.4.2", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "6.1.1", + "vite": "8.3.2", + "typescript": "7.0.2", + "@playwright/test": "1.63.0" + } +} diff --git a/applications/local-query-board/playwright.config.ts b/applications/local-query-board/playwright.config.ts new file mode 100644 index 00000000..1bebd513 --- /dev/null +++ b/applications/local-query-board/playwright.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "@playwright/test"; +export default defineConfig({ + testDir: "tests", + testMatch: "browser*.spec.ts", + workers: 1, + use: { + baseURL: process.env.APP_ORIGIN ?? "http://127.0.0.1:3000", + headless: true, + viewport: { width: 1440, height: 1080 }, + }, + timeout: 90000, +}); diff --git a/applications/local-query-board/sql/publication.sql b/applications/local-query-board/sql/publication.sql new file mode 100644 index 00000000..7eed1088 --- /dev/null +++ b/applications/local-query-board/sql/publication.sql @@ -0,0 +1,6 @@ +-- Run as the service owner after the migration-owned tables exist. +CREATE PUBLICATION electric_publication_local_query_board FOR TABLE planning.projects, planning.tasks; +-- PostgreSQL requires temporary CREATE permission when transferring ownership. +GRANT CREATE ON DATABASE postgres TO planning_sync; +ALTER PUBLICATION electric_publication_local_query_board OWNER TO planning_sync; +REVOKE CREATE ON DATABASE postgres FROM planning_sync; diff --git a/applications/local-query-board/sql/roles.sql b/applications/local-query-board/sql/roles.sql new file mode 100644 index 00000000..1ac62e77 --- /dev/null +++ b/applications/local-query-board/sql/roles.sql @@ -0,0 +1,9 @@ +-- Credentials come from the environment, not command-line arguments. +\getenv migration_password PG_MIGRATION_PASSWORD +\getenv app_password PG_APP_PASSWORD +\getenv sync_password PG_SYNC_PASSWORD +CREATE ROLE planning_migration LOGIN PASSWORD :'migration_password'; +CREATE ROLE planning_app LOGIN PASSWORD :'app_password'; +CREATE ROLE planning_sync LOGIN REPLICATION PASSWORD :'sync_password'; +GRANT CONNECT ON DATABASE postgres TO planning_migration, planning_app, planning_sync; +GRANT CREATE ON DATABASE postgres TO planning_migration; diff --git a/applications/local-query-board/sql/schema.sql b/applications/local-query-board/sql/schema.sql new file mode 100644 index 00000000..bb95be26 --- /dev/null +++ b/applications/local-query-board/sql/schema.sql @@ -0,0 +1,24 @@ +BEGIN; +CREATE SCHEMA planning; +CREATE TABLE planning.projects ( + id uuid PRIMARY KEY, board_id text NOT NULL, name text NOT NULL, + color text NOT NULL CHECK (color ~ '^#[0-9A-Fa-f]{6}$'), + UNIQUE(id, board_id) +); +CREATE TABLE planning.tasks ( + id uuid PRIMARY KEY, board_id text NOT NULL, project_id uuid NOT NULL, + title text NOT NULL CHECK (length(trim(title)) BETWEEN 1 AND 120), + status text NOT NULL CHECK (status IN ('todo','doing','done')), + priority text NOT NULL CHECK (priority IN ('low','medium','high')), + revision integer NOT NULL DEFAULT 1 CHECK (revision > 0), + FOREIGN KEY(project_id,board_id) REFERENCES planning.projects(id,board_id) +); +CREATE INDEX tasks_board ON planning.tasks(board_id); +CREATE INDEX projects_board ON planning.projects(board_id); +ALTER TABLE planning.tasks REPLICA IDENTITY FULL; +ALTER TABLE planning.projects REPLICA IDENTITY FULL; +GRANT USAGE ON SCHEMA planning TO planning_app, planning_sync; +GRANT SELECT ON planning.projects TO planning_app; +GRANT SELECT, INSERT, UPDATE ON planning.tasks TO planning_app; +GRANT SELECT ON planning.projects, planning.tasks TO planning_sync; +COMMIT; diff --git a/applications/local-query-board/sql/seed.sql b/applications/local-query-board/sql/seed.sql new file mode 100644 index 00000000..7e9169f7 --- /dev/null +++ b/applications/local-query-board/sql/seed.sql @@ -0,0 +1,14 @@ +BEGIN; +INSERT INTO planning.projects VALUES +('10000000-0000-4000-8000-000000000001','studio','Website','#5686d8'), +('10000000-0000-4000-8000-000000000002','studio','Launch','#de8054'), +('10000000-0000-4000-8000-000000000003','studio','Research','#7f9866'), +('10000000-0000-4000-8000-000000000099','private-lab','Hidden project','#999999'); +INSERT INTO planning.tasks(id,board_id,project_id,title,status,priority) VALUES +('20000000-0000-4000-8000-000000000001','studio','10000000-0000-4000-8000-000000000001','Review the homepage copy','doing','high'), +('20000000-0000-4000-8000-000000000002','studio','10000000-0000-4000-8000-000000000001','Simplify the signup journey','todo','medium'), +('20000000-0000-4000-8000-000000000003','studio','10000000-0000-4000-8000-000000000002','Prepare the launch checklist','doing','high'), +('20000000-0000-4000-8000-000000000004','studio','10000000-0000-4000-8000-000000000003','Summarize interview findings','done','medium'), +('20000000-0000-4000-8000-000000000005','studio','10000000-0000-4000-8000-000000000002','Draft the announcement','todo','low'), +('20000000-0000-4000-8000-000000000099','private-lab','10000000-0000-4000-8000-000000000099','Private laboratory task','todo','high'); +COMMIT; diff --git a/applications/local-query-board/src/client/collections.ts b/applications/local-query-board/src/client/collections.ts new file mode 100644 index 00000000..515d66b2 --- /dev/null +++ b/applications/local-query-board/src/client/collections.ts @@ -0,0 +1,25 @@ +import { createCollection } from "@tanstack/db"; +import { electricCollectionOptions } from "@tanstack/electric-db-collection"; +import { taskSchema, projectSchema } from "../shared"; +export const tasks = createCollection( + electricCollectionOptions({ + id: "studio-tasks", + syncMode: "eager", + schema: taskSchema, + getKey: (row) => row.id, + shapeOptions: { + url: new URL("/api/shapes/tasks", window.location.origin).toString(), + }, + }), +); +export const projects = createCollection( + electricCollectionOptions({ + id: "studio-projects", + syncMode: "eager", + schema: projectSchema, + getKey: (row) => row.id, + shapeOptions: { + url: new URL("/api/shapes/projects", window.location.origin).toString(), + }, + }), +); diff --git a/applications/local-query-board/src/client/main.tsx b/applications/local-query-board/src/client/main.tsx new file mode 100644 index 00000000..37c0e326 --- /dev/null +++ b/applications/local-query-board/src/client/main.tsx @@ -0,0 +1,291 @@ +import React, { useState } from "react"; +import { createRoot } from "react-dom/client"; +import { useLiveQuery } from "@tanstack/react-db"; +import { eq } from "@tanstack/db"; +import { tasks, projects } from "./collections"; +import { statuses, priorities } from "../shared"; +import "./style.css"; +function App() { + const [filter, setFilter] = useState("all"); + const [projectFilter, setProjectFilter] = useState("all"); + const [title, setTitle] = useState(""); + const [project, setProject] = useState( + "10000000-0000-4000-8000-000000000001", + ); + const [priority, setPriority] = + useState<(typeof priorities)[number]>("medium"); + const [pending, setPending] = useState(false); + const [message, setMessage] = useState(""); + const { data: projectRows = [] } = useLiveQuery((q) => + q.from({ project: projects }).orderBy(({ project }) => project.name), + ); + const { + data: rows = [], + isLoading, + isError, + } = useLiveQuery( + (q) => { + let query = q + .from({ task: tasks }) + .join( + { project: projects }, + ({ task, project }) => eq(task.project_id, project.id), + "inner", + ); + if (filter !== "all") + query = query.where(({ task }) => eq(task.status, filter)); + if (projectFilter !== "all") + query = query.where(({ task }) => eq(task.project_id, projectFilter)); + return query + .orderBy(({ task }) => task.title) + .select(({ task, project }) => ({ + ...task, + project_name: project.name, + project_color: project.color, + })); + }, + [filter, projectFilter], + ); + async function write( + url: string, + method: string, + body: unknown, + onCommitted?: () => void, + ) { + setPending(true); + setMessage("Saving to Postgres…"); + try { + const response = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error); + onCommitted?.(); + setMessage("Committed. Waiting for the live stream…"); + try { + await tasks.utils.awaitTxId(result.txid, 15000); + setMessage("Saved and synchronized"); + } catch { + setMessage( + "Saved to Postgres. The live stream is catching up; please do not submit it again.", + ); + } + return true; + } catch (error) { + setMessage(error instanceof Error ? error.message : "Could not save"); + return false; + } finally { + setPending(false); + } + } + return ( +
+
+ + ◈ FIELDWORK + + LIVE PLANNING / 01 +
+
+
+

A shared place to move work forward

+

+ Small tasks. +
+ Shared momentum. +

+

+ Plan across projects. Edits flow from Postgres into every open + board. +

+
+ +
+
+
+ + +
+ Filters run locally on the synced collections +
+ {isError && ( +

+ The live stream could not load. Check the app and Electric logs. +

+ )} +
+ {statuses.map((status) => ( +
+

+ + {status === "todo" + ? "To do" + : status === "doing" + ? "In progress" + : "Done"} + + {rows.filter((t) => t.status === status).length} +

+ {rows + .filter((t) => t.status === status) + .map((t) => ( +
+
+ + {t.project_name} + + r{t.revision} +
+

{t.title}

+
+ + +
+
+ ))} + {!isLoading && !rows.some((t) => t.status === status) && ( +

Nothing here yet.

+ )} +
+ ))} +
+
{ + e.preventDefault(); + await write( + "/api/tasks", + "POST", + { title, project_id: project, priority }, + () => setTitle(""), + ); + }} + > +
+

Next on the list

+

Add a task

+
+ + + + +
+

+ {message || + "Open a second tab to see changes arrive through the live stream."} +

+
+ A trusted local workbench · server writes remain authoritative · no + offline writes +
+
+ ); +} +createRoot(document.getElementById("root")!).render(); diff --git a/applications/local-query-board/src/client/style.css b/applications/local-query-board/src/client/style.css new file mode 100644 index 00000000..e9355ca2 --- /dev/null +++ b/applications/local-query-board/src/client/style.css @@ -0,0 +1,276 @@ +@import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=DM+Serif+Display:ital@0;1&display=swap"); +:root { + font-family: "DM Sans", sans-serif; + color: #24352f; + background: #f6f5f0; + font-synthesis: none; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +main { + max-width: 1240px; + margin: auto; + padding: 36px 44px; +} +header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 24px; + border-bottom: 1px solid #cdd4c9; +} +.brand { + font-weight: 700; + letter-spacing: 2px; + color: #35584c; + text-decoration: none; + font-size: 18px; +} +.edition, +.eyebrow { + font-size: 11px; + letter-spacing: 1.9px; + text-transform: uppercase; + color: #637568; +} +.intro { + display: flex; + justify-content: space-between; + align-items: center; + padding: 44px 0 32px; +} +h1 { + font-family: "DM Serif Display", Georgia, serif; + font-size: 58px; + line-height: 1.04; + font-weight: 400; + margin: 13px 0 18px; +} +h1 em { + color: #607d63; +} +.lede { + font-size: 14px; + color: #6b766d; +} +.intro aside { + border: 1px solid #d7dfd2; + background: #eff2e9; + border-radius: 8px; + padding: 24px 30px; + font-size: 13px; +} +.intro aside p { + color: #738173; + font-size: 12px; + margin: 10px 0 22px; +} +.intro aside strong { + display: block; + font-size: 20px; + font-weight: 500; +} +.pulse { + display: inline-block; + width: 7px; + height: 7px; + margin-right: 8px; + background: #739168; + border-radius: 50%; +} +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + border-top: 1px solid #d5dacf; + border-bottom: 1px solid #d5dacf; + padding: 17px 0; +} +.toolbar > div { + display: flex; + gap: 18px; +} +.toolbar > span { + font-size: 12px; + color: #7b867c; +} +label { + font-size: 10px; + letter-spacing: 0.5px; + color: #728073; + display: flex; + flex-direction: column; + gap: 6px; +} +select, +input { + font-family: inherit; + color: #354d40; + background: #fff; + border: 1px solid #d2dacd; + border-radius: 5px; + padding: 9px 11px; + font-size: 12px; +} +select { + cursor: pointer; +} +.board { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + padding: 28px 0; +} +.column { + background: #eceedf66; + border: 1px solid #e0e4d9; + border-radius: 8px; + padding: 17px; + min-height: 312px; +} +.column h2 { + display: flex; + justify-content: space-between; + font-size: 12px; + font-weight: 600; + margin: 0 0 17px; +} +.column h2 b { + font-weight: 400; + background: #dee6d8; + padding: 2px 7px; + border-radius: 4px; + font-size: 10px; +} +.task { + background: #fff; + border: 1px solid #dde3d6; + border-radius: 6px; + padding: 17px; + margin: 0 0 12px; + box-shadow: 0 2px 2px #31473204; +} +.task-meta { + display: flex; + justify-content: space-between; + font-size: 10px; + font-weight: 600; +} +.task-meta small { + font-weight: 400; + color: #98a195; +} +.task h3 { + font-size: 14px; + font-weight: 500; + line-height: 1.5; + margin: 14px 0 19px; + min-height: 40px; +} +.task footer { + display: flex; + justify-content: space-between; + gap: 7px; +} +.task select { + font-size: 10px; + padding: 6px; + background: #f8f9f5; + border-color: #e4e8de; +} +.empty { + font-size: 12px; + color: #9aa492; + padding: 20px 0; +} +.compose { + display: flex; + align-items: end; + gap: 15px; + background: #fff; + border: 1px solid #dbe1d3; + border-radius: 8px; + padding: 22px; +} +.compose h2 { + font-family: "DM Serif Display", serif; + font-size: 22px; + font-weight: 400; + margin: 0; +} +.compose .eyebrow { + font-size: 9px; + margin-top: 0; +} +.title-field { + flex: 1; +} +.compose button { + border: 0; + background: #3e6252; + color: white; + border-radius: 5px; + padding: 11px 17px; + font-family: inherit; + font-size: 12px; + cursor: pointer; +} +.notice { + font-size: 12px; + color: #61715f; + min-height: 18px; + margin: 18px 0; +} +.footnote { + border-top: 1px solid #d5dacf; + padding-top: 18px; + font-size: 10px; + color: #899284; + letter-spacing: 0.4px; +} +.sr { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); +} +button:disabled, +select:disabled, +input:disabled { + opacity: 0.6; + cursor: wait; +} +@media (max-width: 800px) { + main { + padding: 24px; + } + .intro { + display: block; + } + h1 { + font-size: 44px; + } + .intro aside { + margin-top: 24px; + } + .toolbar { + gap: 12px; + } + .toolbar > span { + display: none; + } + .board { + grid-template-columns: 1fr; + } + .compose { + flex-wrap: wrap; + } + .title-field { + min-width: 70%; + } +} diff --git a/applications/local-query-board/src/client/vite-env.d.ts b/applications/local-query-board/src/client/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/applications/local-query-board/src/client/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/applications/local-query-board/src/server/app.ts b/applications/local-query-board/src/server/app.ts new file mode 100644 index 00000000..42275e36 --- /dev/null +++ b/applications/local-query-board/src/server/app.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; +import { Hono } from "hono"; +import type { Database } from "./database"; +import { shapeUrl } from "./proxy"; +import { createTask, updateTask, WriteError } from "./writes"; +export function createApp( + db: Database, + origin: string, + engine: string, + secret: string, +) { + const app = new Hono(); + app.use("*", async (c, next) => { + if (c.req.header("host") !== new URL(origin).host) + return c.json({ error: "Unexpected host" }, 403); + if ( + (c.req.method !== "GET" || c.req.header("origin")) && + c.req.header("origin") !== origin + ) + return c.json({ error: "Unexpected origin" }, 403); + await next(); + }); + app.get("/api/shapes/:kind", async (c) => { + let url: URL; + try { + url = shapeUrl(c.req.url, c.req.param("kind"), engine, secret); + } catch { + return c.json({ error: "Unsupported shape or parameter" }, 400); + } + const response = await fetch(url, { signal: c.req.raw.signal }); + const headers = new Headers(response.headers); + headers.delete("content-encoding"); + headers.delete("content-length"); + // Same-origin proxy: upstream CORS policy is not this local app's policy. + for (const key of [...headers.keys()]) + if (key.startsWith("access-control-")) headers.delete(key); + headers.set("cache-control", "no-store"); + return new Response(response.body, { status: response.status, headers }); + }); + app.post("/api/tasks", async (c) => + c.json(await createTask(db, await c.req.json()), 201), + ); + app.patch("/api/tasks/:id", async (c) => { + if (!z.string().uuid().safeParse(c.req.param("id")).success) + return c.json({ error: "Invalid task ID" }, 400); + return c.json(await updateTask(db, c.req.param("id"), await c.req.json())); + }); + app.get("/api/health", (c) => c.json({ ok: true })); + app.onError((error, c) => { + if (error instanceof WriteError) + return c.json({ error: error.message }, error.status); + if (error instanceof SyntaxError) + return c.json({ error: "Invalid JSON" }, 400); + console.error("Request failed", error.name); + return c.json({ error: "The request could not be completed" }, 500); + }); + return app; +} diff --git a/applications/local-query-board/src/server/database.ts b/applications/local-query-board/src/server/database.ts new file mode 100644 index 00000000..b0308e3a --- /dev/null +++ b/applications/local-query-board/src/server/database.ts @@ -0,0 +1,89 @@ +import { Pool } from "pg"; +import { drizzle } from "drizzle-orm/node-postgres"; +export async function connectDatabase( + urlText = process.env.DATABASE_URL, + caPath = process.env.PG_CA_CERT_PATH, +) { + if (!urlText) throw new Error("DATABASE_URL is required"); + const url = new URL(urlText); + if ( + !["postgres:", "postgresql:"].includes(url.protocol) || + url.search || + url.hash + ) + throw new Error("Use a plain Postgres URL without query parameters"); + const local = process.env.DATABASE_LOCAL === "true"; + if (local && !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)) + throw new Error("Local mode requires loopback"); + if (!local && !caPath) throw new Error("PG_CA_CERT_PATH required"); + const ca = local ? undefined : await Bun.file(caPath!).text(); + const certificates = + ca?.match( + /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g, + ) ?? []; + if (!local && (certificates.length < 1 || certificates.length > 16)) + throw new Error("CA file must contain 1..16 PEM certificates"); + async function connect(trustedCa: string | undefined) { + const pool = new Pool({ + connectionString: url.toString(), + max: 5, + connectionTimeoutMillis: 10000, + idleTimeoutMillis: 30000, + ssl: local + ? false + : { ca: trustedCa, servername: url.hostname, rejectUnauthorized: true }, + }); + try { + await pool.query("SELECT 1"); + return pool; + } catch (error) { + await pool.end(); + throw error; + } + } + try { + return await connect(ca); + } catch (originalError) { + if ( + errorCode(originalError) !== "CERT_SIGNATURE_FAILURE" || + certificates.length < 2 + ) + throw originalError; + // Bun1.4.2 can reject duplicate-subject roots in a rotation bundle. + // Only supplied anchors are retried; certificate and hostname checks remain enabled. + for (const certificate of certificates) { + try { + return await connect(certificate); + } catch (error) { + if (!CA_ERRORS.has(errorCode(error))) throw error; + } + } + throw originalError; + } +} +function errorCode(error: unknown) { + return error !== null && typeof error === "object" && "code" in error + ? String(error.code) + : ""; +} +const CA_ERRORS = new Set([ + "CERT_SIGNATURE_FAILURE", + "UNABLE_TO_GET_ISSUER_CERT", + "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + "SELF_SIGNED_CERT_IN_CHAIN", + "DEPTH_ZERO_SELF_SIGNED_CERT", + "CERT_HAS_EXPIRED", + "CERT_NOT_YET_VALID", +]); + +export function database(pool: Pool, counter?: { reads: number }) { + return drizzle(pool, { + logger: { + logQuery(query) { + if (/^select\b/i.test(query)) counter && counter.reads++; + }, + }, + }); +} +export type Database = ReturnType; diff --git a/applications/local-query-board/src/server/index.ts b/applications/local-query-board/src/server/index.ts new file mode 100644 index 00000000..3ccb0c99 --- /dev/null +++ b/applications/local-query-board/src/server/index.ts @@ -0,0 +1,42 @@ +import { connectDatabase, database } from "./database"; +import { createApp } from "./app"; +const origin = process.env.APP_ORIGIN ?? "http://127.0.0.1:3000"; +const parsed = new URL(origin); +if ( + parsed.protocol !== "http:" || + parsed.hostname !== "127.0.0.1" || + parsed.pathname !== "/" +) + throw new Error("APP_ORIGIN must be an HTTP loopback origin"); +const engine = process.env.ELECTRIC_URL ?? "http://127.0.0.1:3005"; +if (new URL(engine).hostname !== "127.0.0.1") + throw new Error("Electric must be local to this workbench"); +if (!process.env.ELECTRIC_SECRET) + throw new Error("ELECTRIC_SECRET is required"); +const pool = await connectDatabase(); +const app = createApp( + database(pool), + origin, + engine, + process.env.ELECTRIC_SECRET, +); +app.get("*", async (c) => { + const path = c.req.path === "/" ? "/index.html" : c.req.path; + if (path.includes("..")) return c.notFound(); + const file = Bun.file(`dist${path}`); + if (await file.exists()) return new Response(file); + return c.notFound(); +}); +const server = Bun.serve({ + hostname: "127.0.0.1", + port: Number(parsed.port || 80), + fetch: app.fetch, + idleTimeout: 60, +}); +console.log(`Fieldwork on ${origin}`); +for (const signal of ["SIGINT", "SIGTERM"] as const) + process.on(signal, async () => { + await server.stop(true); + await pool.end(); + process.exit(0); + }); diff --git a/applications/local-query-board/src/server/proxy.ts b/applications/local-query-board/src/server/proxy.ts new file mode 100644 index 00000000..eff2faf4 --- /dev/null +++ b/applications/local-query-board/src/server/proxy.ts @@ -0,0 +1,42 @@ +const protocol = new Set([ + "offset", + "handle", + "live", + "cursor", + "expired_handle", + "cache-buster", + "log", +]); +const shapes = { + tasks: { + table: "planning.tasks", + columns: "id,board_id,project_id,title,status,priority,revision", + }, + projects: { table: "planning.projects", columns: "id,board_id,name,color" }, +} as const; +export function shapeUrl( + requestUrl: string, + kind: string, + engine: string, + secret: string, +) { + if (!Object.hasOwn(shapes, kind)) throw new Error("Unknown shape"); + const source = new URL(requestUrl); + const target = new URL("/v1/shape", engine); + for (const [key, value] of source.searchParams) { + if ( + (key === "log" && value !== "full") || + !protocol.has(key) || + source.searchParams.getAll(key).length !== 1 + ) + throw new Error("Unsupported shape parameter"); + target.searchParams.set(key, value); + } + const shape = shapes[kind as keyof typeof shapes]; + target.searchParams.set("table", shape.table); + target.searchParams.set("columns", shape.columns); + target.searchParams.set("where", "board_id = $1"); + target.searchParams.set("params[1]", "studio"); + target.searchParams.set("secret", secret); + return target; +} diff --git a/applications/local-query-board/src/server/schema.ts b/applications/local-query-board/src/server/schema.ts new file mode 100644 index 00000000..36a2dad5 --- /dev/null +++ b/applications/local-query-board/src/server/schema.ts @@ -0,0 +1,17 @@ +import { pgSchema, uuid, text, integer } from "drizzle-orm/pg-core"; +const board = pgSchema("planning"); +export const projects = board.table("projects", { + id: uuid().primaryKey(), + board_id: text().notNull(), + name: text().notNull(), + color: text().notNull(), +}); +export const tasks = board.table("tasks", { + id: uuid().primaryKey(), + board_id: text().notNull(), + project_id: uuid().notNull(), + title: text().notNull(), + status: text().notNull(), + priority: text().notNull(), + revision: integer().notNull(), +}); diff --git a/applications/local-query-board/src/server/writes.ts b/applications/local-query-board/src/server/writes.ts new file mode 100644 index 00000000..c3397a15 --- /dev/null +++ b/applications/local-query-board/src/server/writes.ts @@ -0,0 +1,76 @@ +import { and, eq, sql } from "drizzle-orm"; +import type { Database } from "./database"; +import { projects, tasks } from "./schema"; +import { createTaskSchema, updateTaskSchema } from "../shared"; +export class WriteError extends Error { + constructor( + public status: 400 | 404 | 409, + message: string, + ) { + super(message); + } +} +export async function createTask(db: Database, input: unknown) { + const body = createTaskSchema.safeParse(input); + if (!body.success) + throw new WriteError(400, "A title, project and priority are required."); + return db.transaction(async (tx) => { + const [project] = await tx + .select() + .from(projects) + .where( + and( + eq(projects.id, body.data.project_id), + eq(projects.board_id, "studio"), + ), + ); + if (!project) throw new WriteError(400, "Choose a project on this board."); + const [task] = await tx + .insert(tasks) + .values({ + id: crypto.randomUUID(), + board_id: "studio", + project_id: project.id, + title: body.data.title, + status: "todo", + priority: body.data.priority, + revision: 1, + }) + .returning(); + const xid = await tx.execute<{ txid: string }>( + sql`SELECT pg_current_xact_id()::xid::text AS txid`, + ); + return { task, txid: Number(xid.rows[0]!.txid) }; + }); +} +export async function updateTask(db: Database, id: string, input: unknown) { + const body = updateTaskSchema.safeParse(input); + if (!body.success) + throw new WriteError(400, "Choose a valid status, priority and revision."); + return db.transaction(async (tx) => { + const [task] = await tx + .update(tasks) + .set({ + status: body.data.status, + priority: body.data.priority, + revision: sql`${tasks.revision}+1`, + }) + .where( + and( + eq(tasks.id, id), + eq(tasks.board_id, "studio"), + eq(tasks.revision, body.data.revision), + ), + ) + .returning(); + if (!task) + throw new WriteError( + 409, + "This task changed. Review its latest version and try again.", + ); + const xid = await tx.execute<{ txid: string }>( + sql`SELECT pg_current_xact_id()::xid::text AS txid`, + ); + return { task, txid: Number(xid.rows[0]!.txid) }; + }); +} diff --git a/applications/local-query-board/src/shared.ts b/applications/local-query-board/src/shared.ts new file mode 100644 index 00000000..789cc5d0 --- /dev/null +++ b/applications/local-query-board/src/shared.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +export const statuses = ["todo", "doing", "done"] as const; +export const priorities = ["low", "medium", "high"] as const; +export const taskSchema = z.object({ + id: z.string(), + board_id: z.string(), + project_id: z.string(), + title: z.string(), + status: z.enum(statuses), + priority: z.enum(priorities), + revision: z.number().int().positive(), +}); +export const projectSchema = z.object({ + id: z.string(), + board_id: z.string(), + name: z.string(), + color: z.string(), +}); +export const createTaskSchema = z + .object({ + title: z.string().trim().min(1).max(120), + project_id: z.string().uuid(), + priority: z.enum(priorities), + }) + .strict(); +export const updateTaskSchema = z + .object({ + revision: z.number().int().positive().max(2147483647), + status: z.enum(statuses), + priority: z.enum(priorities), + }) + .strict(); diff --git a/applications/local-query-board/tests/browser-resilience.spec.ts b/applications/local-query-board/tests/browser-resilience.spec.ts new file mode 100644 index 00000000..3c175911 --- /dev/null +++ b/applications/local-query-board/tests/browser-resilience.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +test("committed task survives interrupted stream and real engine restart", async ({ + page, + context, +}) => { + const container = process.env.ELECTRIC_TEST_CONTAINER; + test.skip( + !container, + "Set ELECTRIC_TEST_CONTAINER to this example's owned engine container.", + ); + if ( + !container || + !/^(pgts-board-electric-(native|managed)|fieldwork-electric)$/.test( + container, + ) + ) + throw new Error("Unexpected test container"); + const other = await context.newPage(); + await page.goto("/"); + await other.goto("/"); + await expect( + page.getByText("Review the homepage copy", { exact: true }), + ).toBeVisible({ timeout: 20000 }); + await expect( + other.getByText("Review the homepage copy", { exact: true }), + ).toBeVisible({ timeout: 20000 }); + execFileSync("docker", ["stop", container], { stdio: "ignore" }); + const title = "Stream recovery " + Date.now(); + try { + await page.getByLabel("Task title", { exact: true }).fill(title); + const committed = page.waitForResponse( + (r) => + new URL(r.url()).pathname === "/api/tasks" && + r.request().method() === "POST", + ); + await page.getByRole("button", { name: "Add task" }).click(); + const response = await committed; + expect(response.status()).toBe(201); + const result = await response.json(); + expect(result.task.title).toBe(title); + await expect(page.getByLabel("Task title", { exact: true })).toHaveValue( + "", + ); + await expect(page.getByRole("status")).toContainText( + "Saved to Postgres. The live stream is catching up", + { timeout: 20000 }, + ); + } finally { + execFileSync("docker", ["start", container], { stdio: "ignore" }); + } + await expect( + page.getByRole("heading", { name: title, exact: true }), + ).toBeVisible({ timeout: 35000 }); + await expect( + other.getByRole("heading", { name: title, exact: true }), + ).toBeVisible({ timeout: 35000 }); + expect( + await page.getByRole("heading", { name: title, exact: true }).count(), + ).toBe(1); +}); diff --git a/applications/local-query-board/tests/browser.spec.ts b/applications/local-query-board/tests/browser.spec.ts new file mode 100644 index 00000000..bbe45cf6 --- /dev/null +++ b/applications/local-query-board/tests/browser.spec.ts @@ -0,0 +1,103 @@ +import { test, expect } from "@playwright/test"; +test("two tabs receive CDC, join projects, filter locally, and rejected writes stay rejected", async ({ + page, + context, +}) => { + const second = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + second.on("pageerror", (e) => errors.push(e.message)); + await page.goto("/"); + await second.goto("/"); + await expect( + page.getByText("Review the homepage copy", { exact: true }), + ).toBeVisible({ timeout: 20000 }); + await expect( + second.getByText("Review the homepage copy", { exact: true }), + ).toBeVisible({ timeout: 20000 }); + await expect( + page.getByText("Private laboratory task", { exact: true }), + ).toHaveCount(0); + for (const param of [ + "table=planning.projects", + "where=true", + "params[1]=private-lab", + "columns=*", + ]) { + const r = await page.request.get(`/api/shapes/tasks?offset=-1&${param}`); + expect(r.status()).toBe(400); + } + const reads: string[] = []; + second.on("request", (r) => { + if (r.method() === "GET" && r.url().includes("/api/")) + reads.push(new URL(r.url()).pathname); + }); + const rejectedTitle = "Rejected hidden project " + Date.now(); + const invalid = await page.request.post("/api/tasks", { + headers: { Origin: await page.evaluate(() => location.origin) }, + data: { + title: rejectedTitle, + project_id: "10000000-0000-4000-8000-000000000099", + priority: "high", + }, + }); + expect(invalid.status()).toBe(400); + const title = "Launch note " + Date.now(); + await page.getByLabel("Task title", { exact: true }).fill(title); + await page + .getByLabel("New task project") + .selectOption("10000000-0000-4000-8000-000000000002"); + await page.getByRole("button", { name: "Add task" }).click(); + await expect(page.getByRole("status")).toHaveText("Saved and synchronized", { + timeout: 25000, + }); + const card = second + .getByTestId("task") + .filter({ has: second.getByRole("heading", { name: title, exact: true }) }); + await expect(card).toBeVisible(); + await expect(card.getByText("Launch", { exact: true })).toBeVisible(); + await expect(second.getByText(rejectedTitle, { exact: true })).toHaveCount(0); + await page + .getByLabel(`Status for ${title}`, { exact: true }) + .selectOption("doing"); + await expect(page.getByRole("status")).toHaveText("Saved and synchronized", { + timeout: 25000, + }); + await expect( + second.getByLabel(`Status for ${title}`, { exact: true }), + ).toHaveValue("doing"); + await second + .getByLabel("Status filter", { exact: true }) + .selectOption("done"); + await expect(card).toHaveCount(0); + await second + .getByLabel("Status filter", { exact: true }) + .selectOption("doing"); + await expect(card).toBeVisible(); + await page.route("**/api/tasks", (route) => + route.fulfill({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ error: "Deliberate rejected write" }), + }), + ); + await page + .getByLabel("Task title", { exact: true }) + .fill("Must never appear"); + await page.getByRole("button", { name: "Add task" }).click(); + await expect(page.getByRole("status")).toHaveText( + "Deliberate rejected write", + ); + await expect( + second.getByText("Must never appear", { exact: true }), + ).toHaveCount(0); + await page.unroute("**/api/tasks"); + expect(reads.every((path) => path.startsWith("/api/shapes/"))).toBe(true); + expect(reads.length).toBeGreaterThan(0); + expect(errors).toEqual([]); + await second.getByLabel("Status filter", { exact: true }).selectOption("all"); + await second.screenshot({ + path: "docs/local-query-board.png", + fullPage: true, + }); +}); diff --git a/applications/local-query-board/tests/database.test.ts b/applications/local-query-board/tests/database.test.ts new file mode 100644 index 00000000..53a049c4 --- /dev/null +++ b/applications/local-query-board/tests/database.test.ts @@ -0,0 +1,60 @@ +import { test, expect, afterAll } from "bun:test"; +import { eq, sql } from "drizzle-orm"; +import { connectDatabase, database } from "../src/server/database"; +import { tasks } from "../src/server/schema"; +import { createTask, updateTask } from "../src/server/writes"; +const pool = await connectDatabase(); +const db = database(pool); +afterAll(() => pool.end()); +test("same-transaction IDs, concurrent revisions, invalid writes and runtime grants", async () => { + const created = await createTask(db, { + title: "Integration " + crypto.randomUUID(), + project_id: "10000000-0000-4000-8000-000000000001", + priority: "medium", + }); + expect(Number.isInteger(created.txid)).toBe(true); + const attempts = await Promise.allSettled( + Array.from({ length: 8 }, () => + updateTask(db, created.task.id, { + revision: 1, + status: "doing", + priority: "high", + }), + ), + ); + expect(attempts.filter((x) => x.status === "fulfilled")).toHaveLength(1); + expect(attempts.filter((x) => x.status === "rejected")).toHaveLength(7); + const [saved] = await db + .select() + .from(tasks) + .where(eq(tasks.id, created.task.id)); + expect(saved!.revision).toBe(2); + expect(saved!.status).toBe("doing"); + await expect( + createTask(db, { + title: "Hidden", + project_id: "10000000-0000-4000-8000-000000000099", + priority: "medium", + }), + ).rejects.toThrow(); + await expect( + updateTask(db, created.task.id, { + revision: 2, + status: "invented", + priority: "high", + }), + ).rejects.toThrow(); + const [after] = await db + .select() + .from(tasks) + .where(eq(tasks.id, created.task.id)); + expect(after).toEqual(saved); + await expect( + Promise.resolve(db.execute(sql`CREATE TABLE planning.forbidden(id int)`)), + ).rejects.toThrow(); + await expect( + Promise.resolve( + db.execute(sql`DELETE FROM planning.tasks WHERE id=${created.task.id}`), + ), + ).rejects.toThrow(); +}); diff --git a/applications/local-query-board/tests/proxy.test.ts b/applications/local-query-board/tests/proxy.test.ts new file mode 100644 index 00000000..0d2dce4f --- /dev/null +++ b/applications/local-query-board/tests/proxy.test.ts @@ -0,0 +1,68 @@ +import { test, expect } from "bun:test"; +import { shapeUrl } from "../src/server/proxy"; +test("protocol advances preserve fixed board shape; arbitrary filters never reach Electric", () => { + const u = shapeUrl( + "http://localhost/api/shapes/tasks?offset=-1&handle=known", + "tasks", + "http://127.0.0.1:3005", + "server-secret", + ); + expect(u.searchParams.get("where")).toBe("board_id = $1"); + expect(u.searchParams.get("params[1]")).toBe("studio"); + expect(u.searchParams.get("offset")).toBe("-1"); + for (const param of [ + "table=pg_authid", + "where=true", + "columns=*", + "params[1]=private-lab", + "secret=other", + "subset__where=true", + "offset=-1&offset=9", + ]) + expect(() => + shapeUrl( + `http://localhost/api/shapes/tasks?${param}`, + "tasks", + "http://127.0.0.1:3005", + "secret", + ), + ).toThrow(); + expect(() => + shapeUrl("http://localhost/", "unknown", "http://localhost/", "secret"), + ).toThrow(); + for (const name of ["__proto__", "constructor", "toString"]) + expect(() => + shapeUrl("http://localhost/", name, "http://localhost/", "secret"), + ).toThrow(); +}); +import { createApp } from "../src/server/app"; +import type { Database } from "../src/server/database"; +import { updateTaskSchema } from "../src/shared"; +test("local origin and UUID validation refuse requests before database access", async () => { + const app = createApp( + undefined as unknown as Database, + "http://127.0.0.1:3000", + "http://127.0.0.1:3005", + "test-secret", + ); + const foreign = await app.request( + "http://127.0.0.1:3000/api/shapes/tasks?offset=-1", + { headers: { Host: "127.0.0.1:3000", Origin: "https://foreign.invalid" } }, + ); + expect(foreign.status).toBe(403); + const malformed = await app.request( + "http://127.0.0.1:3000/api/tasks/------------------------------------", + { + method: "PATCH", + headers: { Host: "127.0.0.1:3000", Origin: "http://127.0.0.1:3000" }, + }, + ); + expect(malformed.status).toBe(400); + expect( + updateTaskSchema.safeParse({ + revision: 2147483648, + status: "todo", + priority: "low", + }).success, + ).toBe(false); +}); diff --git a/applications/local-query-board/tsconfig.json b/applications/local-query-board/tsconfig.json new file mode 100644 index 00000000..63740d9d --- /dev/null +++ b/applications/local-query-board/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["bun"], + "noEmit": true + }, + "include": ["src", "scripts", "tests", "*.ts"] +} diff --git a/applications/local-query-board/vite.config.ts b/applications/local-query-board/vite.config.ts new file mode 100644 index 00000000..1826ab6a --- /dev/null +++ b/applications/local-query-board/vite.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +export default defineConfig({ plugins: [react()] });