From 403e34ebfb34d13933e225370797262c3a576918 Mon Sep 17 00:00:00 2001 From: sdairs Date: Mon, 5 Oct 2026 10:34:27 +0100 Subject: [PATCH] Add Electric and TanStack DB planning board example --- .github/workflows/local-query-board.yml | 23 ++ applications/local-query-board/.env.example | 6 + applications/local-query-board/.gitignore | 7 + applications/local-query-board/README.md | 174 +++++++++++ applications/local-query-board/bun.lock | 244 +++++++++++++++ .../docs/local-query-board.png | Bin 0 -> 87703 bytes applications/local-query-board/index.html | 1 + applications/local-query-board/package.json | 35 +++ .../local-query-board/playwright.config.ts | 12 + .../local-query-board/sql/publication.sql | 6 + applications/local-query-board/sql/roles.sql | 9 + applications/local-query-board/sql/schema.sql | 24 ++ applications/local-query-board/sql/seed.sql | 14 + .../src/client/collections.ts | 25 ++ .../local-query-board/src/client/main.tsx | 291 ++++++++++++++++++ .../local-query-board/src/client/style.css | 276 +++++++++++++++++ .../src/client/vite-env.d.ts | 1 + .../local-query-board/src/server/app.ts | 58 ++++ .../local-query-board/src/server/database.ts | 89 ++++++ .../local-query-board/src/server/index.ts | 42 +++ .../local-query-board/src/server/proxy.ts | 42 +++ .../local-query-board/src/server/schema.ts | 17 + .../local-query-board/src/server/writes.ts | 76 +++++ applications/local-query-board/src/shared.ts | 32 ++ .../tests/browser-resilience.spec.ts | 61 ++++ .../local-query-board/tests/browser.spec.ts | 103 +++++++ .../local-query-board/tests/database.test.ts | 60 ++++ .../local-query-board/tests/proxy.test.ts | 68 ++++ applications/local-query-board/tsconfig.json | 14 + applications/local-query-board/vite.config.ts | 3 + 30 files changed, 1813 insertions(+) create mode 100644 .github/workflows/local-query-board.yml create mode 100644 applications/local-query-board/.env.example create mode 100644 applications/local-query-board/.gitignore create mode 100644 applications/local-query-board/README.md create mode 100644 applications/local-query-board/bun.lock create mode 100644 applications/local-query-board/docs/local-query-board.png create mode 100644 applications/local-query-board/index.html create mode 100644 applications/local-query-board/package.json create mode 100644 applications/local-query-board/playwright.config.ts create mode 100644 applications/local-query-board/sql/publication.sql create mode 100644 applications/local-query-board/sql/roles.sql create mode 100644 applications/local-query-board/sql/schema.sql create mode 100644 applications/local-query-board/sql/seed.sql create mode 100644 applications/local-query-board/src/client/collections.ts create mode 100644 applications/local-query-board/src/client/main.tsx create mode 100644 applications/local-query-board/src/client/style.css create mode 100644 applications/local-query-board/src/client/vite-env.d.ts create mode 100644 applications/local-query-board/src/server/app.ts create mode 100644 applications/local-query-board/src/server/database.ts create mode 100644 applications/local-query-board/src/server/index.ts create mode 100644 applications/local-query-board/src/server/proxy.ts create mode 100644 applications/local-query-board/src/server/schema.ts create mode 100644 applications/local-query-board/src/server/writes.ts create mode 100644 applications/local-query-board/src/shared.ts create mode 100644 applications/local-query-board/tests/browser-resilience.spec.ts create mode 100644 applications/local-query-board/tests/browser.spec.ts create mode 100644 applications/local-query-board/tests/database.test.ts create mode 100644 applications/local-query-board/tests/proxy.test.ts create mode 100644 applications/local-query-board/tsconfig.json create mode 100644 applications/local-query-board/vite.config.ts 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 0000000000000000000000000000000000000000..0e9b5eb81c8f86087381ec6b080a37a368111ee6 GIT binary patch literal 87703 zcmeFZXH?T)@IHtQ6a-X6I@ss}BE3dMML>G*N^eQ%p#=~Gq)YE0z4uNaQEAe94I%X2 zTWCo(`2GKO&%WDzv2XroIVb1b6F$kk^W2%aGtbOTu!@o_IVn9U5fKr&+{brnL`1iK z5?(a7uM^&ROs+zB;~tURyEhtcsaq%#uZQNi)BQKXU2IcFb$8b9e0$CDz$5DEYo$ON&-%w0lTVUEW*5#DqVUD(;*AjRKziy7q7MFp`Mm-#|t3I^(~= z>;2pI|2@?H|3CEq8iLN{%!OYI_38Rv1seZQoE!K~)M@@Y4CGZgnJ7Bbu;15S*e3e# zUp{ZSu61iUd_JZLMX^cpbIvNSl^R~QrxXWtpqVtBoG-e8Qy=_Yn$)-AsMuUF(w^Us z6(%g`g;3>iCyf$WTZTY(1F1}HUxiRRKQ@cMqhIecQ^fwB>OzW40t^_C&)9x(`B+c$ zg;FlvQF?Nt(-4|FG1Yb+EyEp6K|`BXYjI#ng5Pm^vtG}#7NA{4Rf24-ShneSs7}$3#OrRf@xi2)qKnCb|+Mvo;tkz=?_UE&~$MmoA zVD(}~pG*2CTO79Uyiup|F?I5l@s=sA97tRt>TI-2_WQx_c#Wdbu<$YnEo!;bFnOpy zmRE+VsQzT%L!sR4_*s3gUB&+MI=k(wzNqicnP3c^=hK(W%mQ zmA?rr2s|oy`Y`AtpxNP&%X0gBqti~4_~g{rrvb_>mi?(;pHx{|_Fn8CPM{@KIBy%7 z?!d)U*NCqqx-`0N&ygV3RJmZ_ym&@FMf{}p*XiV@NU|y8u26@lB;#y?)1;77J_b{} zmi}{}(zWpyc`FkDM3%l!MI2~r!(C0Ll$-i@B1yiE$Lp-J+j17ZzZHBMfQfKHHNwh- zC3p}XKOv^CGheZaUDyFaFGpkLWwb?{QnSPX&$5*+%k?9T?pxlFsx8cD#_YC>g(G0+ znD4>lkfKj=3Vmf%rqA__y?WLzdsFY@UcG-{)8qKcH9z!yx6%HXh}0kE6e~J%Yl|1S z<>I`xK;$pFfnKIZ3mvHis&Ajls~PqS{*So|K@eW92*DdSX^s&G=2j2Y~P z@hBC=^@eXaj!@|^bHOsYSA&)EfY&821#8Vbfz zc?PAk1cORMD{YZ=i?gj@I%dTnyY?^9s;ouUAs|Wqet`M!o&mf5y2RV9iIr#Qffs*o zvp|p<^9dr_mKtQ;OAj57UDN|p@J7qv8SkAJsh6H@N!jFqm!o0wPH$aK=3}|u*psu+ zT12>-F?QMZ>ZU}cgUe#(W~D9}V0xn#<)}0uj@9m9CaG_TJnABD0tI02zYHp#`!(** zdLo~*Pq{CI2MW>Kgdc0|(%Gtedm$>R!tY6(RMN1u8+~SZbY(4#bjR)a3Pp-_>rKri zeg{p};D&P#hR9t-0L|Zf@&c~8$#1>JR{DBIgCVGkwHcDOj37(O1ZDwL>UI|$l?R!u zj{&UQTH8?;tQJ@6I)TSlu9Y!<9gV$KKCEMeuZAE3lP9$JXP$= z`MOQnb}~){_?d_LSJk`b#QRWC?%~Fe~8AlykM{gv#=YGh9y0uf@6$yGus$`aN(Ynl-ey^AsuMgSyEEn#! zw{D= zv<`+wAI!tOtZiPo;*(8q&sM3+j;l7(G8K%E7Lqm625Db6mPNJ-G+&uA%^$uhXQQa) zibrx%m`>0xl1{*#x}0cGeP4sMo89*eh}*(s5=L=t-X@x}9xj^udQ51zQ*NWLXvXm3 zTbo2V%a{xU?HDFdg8kd_j+8U6-V*(o&qBkFH0Z923R_vq9}O1Wb3@l%_MJNu+uj#K zg=-&T!um3rC$P&j9f}O6`!whPDOVo|5 zWPAXpTErklYO0r1yVbi~75x$_yt4?-;o$ZTs@2K#jiy{jaP&ECeyp=oe(2=4(@9a& zk{`)x(NSt9->F~GUqIZBKi-zEuac|mza;7Dn&|0sUHaZJVtm(r?c4OLl!^#m;E=%K zc(TOT(Rj1iWb}BOVj`Dn(yE`hn1r^Ol3P`RjOJC4Wbjzg830sM`>_8Qe=qYXwu0A8 zW35rTt?6=6cRhCjVjNeTFD&^H=p);~#a}%=bCDhAMa_Pqd6R+}*{NgiY^>#OVw`cf zTi7Z~PN^GH^MuveF=soTfV*IIWz)||PG^mrJh65m!7pwFkF}?_M&9nd19zPURu<*V za0*s7T1d()`qkwotOuxs6XibpmHCpnB@dN+*v>BZMENdkGx!=GqCwU}=l$s*Y~{JA zd5Lk2#7;cBUMMKBu=$UFOMhSLI@1oP=*E^{s->7`dq*AmqmX|gzx~{jv$wo*`S-8$ zAch#*YUQQ4I9kYRD4pL$$JxbMCuWe8^VSs1NuHRraLPoqRmk=8UgK;3nEGnB{FIyi zLy}ExTpeFtjtfWZJ2=*1Z&(%e_7_TG{*>-cPAMBq0N!rw=762SK-_$6Q#3VMLc|>e z%$-`Ffau%<`|wY<%N4}6CPiNV{a9FSb;fY~zH=l(h@x+At>_Mgnu72_!1PQGrS$Y_ z(E|f4%uvK##fp3Tt*P#KrpX(z}F3y-t`E~>wKf!|7xV}OP zf zURE?HD}H+SP2;pf%o8l$%J0w4dVx9kugztUA+CJ|tU&X;ZYjD5YJ z`qt#!3b;@S2W3;FWggX&7~pXp1q$d5xW*Tr?)~1H2|0i`PLQ5&yn>$1hqMjh;*HY{ zv(F>9Tilkg`?qgj}$pr|XRT)>&z@3H$Rz+rq71!|m2QY|{k;=ri+ZcbuwX@7!0MS|~zXjKo;X zJj~rFd>UBnBgQ2p5lrhh)F38%7q;DRb@)Ue8g3M2Z7EK9rIA^M{#3AHwR+$ryR*V* z9lcNkM~9y&=i3Dtr}WE<8<99{CHt@jOBNm43r2$35KL$9Lq86k&YSgt-d@{-AjH zl_=<~yGV!VI3c2h01n^%nW7B>|1m5_^AMsjt1u8p9o5&$g1SRUjiv#vZE4zd zYfkg`XY$L%W>l&!-sRGIf8VAa5-iLIJMY@_Z|TK(RA^Yy*PB$vLC|vbi4$i0=02F& zO_tpOTLa>DmE^jU_ImiFA^Yv3J^g%23fE}PNd4aM#f84#k*VQmRvI$L)TeB{4Q%6* zkJv7H0vIACBvrWy01cKn?ERG}Px6sAHUa-8%KtgZTy|?p3FWEd+Ey`niB+yMuRoS3 z939$p9EnJNdf_a_aQ$Uu1b;45HMwR(Kt%Gm$m=rpZ&--_KuJWp2UcgGCF62&8-KYN zzKXBB)Um?PM&Gb6B8`Z1mxWYPRSX`bN@wcNPeX9~vp$3uJ5r++M8~-Ee9m#OkdWE> zs%bLnVB1Z;{Gn={5%$KNn|$e3fKZMD!Jm>I?hfiPcQmsY`K+{1g`4s(1r~f<5lgUA zkT_@@*b$}CkLp_i9Iq#2*d;T*^Vaq=UZ8TaFs1k9P6_FosGafzOOGAo!p0X~Xi7Uf5nwfaS+`?kycN*qNCxkG?!as+Y}NY&6$T zXeCX^oy{fDNukWD?g)`Q>h-jffuHUwqUjEeSZL-oM#howY{2h32rL(`H4*oP)$hC&k`vVG<BOsiRKrKX2`BNnv;e~2 zT&;A_NP{^uYDl$;5GLAA+@Tx*@iaY2O!!M491^x8@4R^MmNKH}b^F(L;}^ng5f0P! zs$rm+g1xnl-CsH}#%fstO=rX;r$aPXAvzH+*-cSi%5Z*)FTGSECr_IGQ4^~ao+|~0$L?N=iH~tMcn6FX%8~k*;`M=piS18EmKlsT}$UbuGO2%KgG%VEY zuf@d2#x-vL6PifA;~w?D(f@wp6U6%8cys^1l8)<|e2&Ebm6>v7bm=5l+G1y8-?oSs zT>NyvE?&}?*ZrRrq8ZH&(X&jcl~Z z15f4j)XCJ&*m~gEbjgm$O!S-+Z&yFXW&~K@WzuKsXh(XrUj*Ap&h8PGHmSMzH#iYv z?}#kI;;8ZYi={Y$pB`=jW)( z&;P`1+68|vH{}m%wk@(Z!5%E5Z`T6^CufQTH$s&YP80jh1=d5H6ePp)4l#b){q2g~ zgeZ;M<5My>K(+81pah2Q<{ftv5 zi9&e=y)>_0b$#~R(iB^RV-rX>FR;g#t+~A6koh7F^^AEN66JR}2O1Mh$~|QpyyMEK zb^nWqoFqxI4=&W0@*as)O`0j~<-dhfh;v1n0xr|-1cq)Bl<1o($*Npnsr)kK_FiGv z412`PaKud06|CYEfdw2t{%?usla!tU?y?77USmq2S%(+`xE>{uBky--x+FYK_oUOQ z*-4N6{AZe5se;hwgG+leX+e&PrDm?f-;+o#))^-epTumP>e)5lDKjD2yOFIU0FTpe z5s-t9&=dxIi{VsjW*wQjMd(=w-;5iyF05vrmJ{4?~Mr z@zf8CYvxyZvljE&9FD_TFW|b(nPnHTa#75FOqL3s3vM9Q(#0zCyW3v*$P2m_G3-(!RiI7&A%l^s{?fGETeNw~{b~8M4oa zw_Hr^-1q<;+OIK957!@y(@0^QbO(ZlNm#aDX?^DzjuI$Su93`YpOtK~@$(;5i`>`b zI$WdO_whTD!F6DFoiy*1;ZS8Mk{lW?U*kRtCM8F(!g8PG#?7eAR5!J!r?ovx3Of-G zDv*3iLJ5F-A?L4a9?xd|{rwEKls-8T$kg0!9aj7+6mUXm@7VC%wH#iwxz{#aV_4XExCoDA~80Zwf|6gP4dQ9#`;rx5T(VcRxBb0rZuRYu--$d{XF6%!ODY< z`+zjScX0Ceh$%^AkIu<@olHPwcJg(Se`%hm#FMw0)08ZXcF%wbRQTOwU+a!nGpXOIx%}cmA%hX8(6Q-huOV&EVZ+rcKopWF1qJ^+g-0EoM0omYZbKNvyB*Y-2LG zA@IX_iLykL$uP+=rTs|a0`?eIr6jt3<2-G5Xv9Nq%K%zG*yi9*WqWMQrzjtp%E&JA zEKI|isrcHIjD)?7E8vBzQny<1f968r_k@eo^o3~r)92EHt6d1d-Wr<4^8kL+bmSf} zGR&OL7NJvH?7l-JYmsRMTvhT$1wqDkk!F>Dzsnp=$=O2gps1edGq^20mRLTzs~A=? z*=zKH5s<`Dr~g_l$#r;cJI?~s=L{1XNrq!jC4d#!p_T7 z{bgM_Co9CWfg*venq1H|E#&YtLh-jJ8c!=6LI5Hzi-z7)%AnWDe)T zsrGd2PT~IkSi|{An!7Rj2btS#P^D93nJGA#d2>jf)hdhK2yl=Rka206ACxa&KsCCb z4XR4LR4c0~RS!~b^8S+*kTHPVoG*S*wjg`&QU$*lYA(yonx-UUO;moVLp0V{cBWUNKN@4J1s!4nS#Xaz*Be~r zwa>nCjWd?370T4R+C)HgmcZ+w%`R6V?q&bKc(x%dsff(Que`o9qd35VYoL!uqzLL) zBOTGf$B1%r0ljfZlT&dgk##rzm=K}>muZj7SsW#NoQR0^|0+}df0-xbKe(Fgr>Vpt zGO0UR-hb%xOS*RSt)GU5L+hlLaWT~~)%7q)zEavv4)y%$WYT|0j5t>sJg=na)8b0m zM#%#gcvdx0O>v+AIh(3~VUyPC?)qw(E6vZ>|D`U@24l5jwSjtIO7OK)!&q%itI-{L zZ35Nzh2me1!xXDztgf%@d;`JFq72^eC?1-)twR4_*;YJD0FlO7%yoG3yy+`Cg?5pn zkPEwAkG{!4`d@;?q-w4h*AZTLZ?ALDenewjgH+|=|N7`t|Dm57y;YH?rr$9}EnSaa ztNvF3Ui*HR&0qe#?+u^3`?FLscGn*UAg$*--lC+sZJ_Dg?nKzfrMT5DXwJumwpW`S;v$yiGTLJnu`c1 z>$_N`rlQf{h-zZ;JkihyE-H2rhqg%n8bD7VB{*Kb6n^zen9Js~3F0-t=h~w-J@G`Z zqhC?2B$3ZqSVS8PvTH7i&Vc9t|Wl!95&sKHBDUBYX$Vt%Bo#dUNr z^H0WX)^bmLxnYXO)?|{vu)^y`Q8D97HmfT9-n=5gK}vcY4Gx)g-R0#~x?<{y4DMZL z4AIumJHXFEA=s5ZMLwCDGWzzU|KS3rWp`S@!;_U-Q+WnXeXS7G;Z_gd_9Tqm`a*a= zAN;l)pCXWNiP`y+p_B3L+d)wgm9DOfM~fM;rR8cU`ljOD52ku64vlMpA6;(wME#rb zF&mv`-L~h6flnt>{7`s1{vreDoE=;qiILz#FtQ+P*Oz}UJsZFyCcXjccPb588cfH1 zjH60PEG_e|zLJ=o&HFr~eipi{*?6~KNrQ>-t-fUMd}V%nPgNb4JFRZm*d!Yji5Jg$ zud{~3I{Mjod3o{YNVK@i#^}+oV(FvDw{Kh+a*l^B=__&NpG+@31x}r9N<{+LIM4-| zGRs9})kW1;OC_TDMiivRB&P_y3|Lhp&s&L|_a}A%Oh~jwfcW`G*p;o%QK&w}*UyZ> zEM3kLP*UE{F=VcytjobPMr&T}<_BNnipAMI{`D|4sN58d-54oH)XA?8Nh#U)`a@7t zg38~Di;Ib1sngQ4ZG|mC1*xJg8^0~ng=y~=^(UM?q)sR}B)bw1aD%W-&X;Y$IZVjX z2c}P--s-%0dXDm53}4{eDLb08YQN>&X@zEH3}|o1NbSF??Qi~3wBfir_lxmgD+Mnt zA%B=|02TvHR2?ekDqt44_lso?F6I;wT7m=Gj&dSV>bjaxG*u)`LcCy$vq{KR%LvgA z!m%IasI)r8mM#v}vro(qS}98sSo=K`wQl1~iA9g-ii!1cep3DCU>pgRX*Cf1ov&$6 zxx}}+25epm2#)i(#uQCE(Xcwy8IhBx$7Xcm^bd|-x3XJuKE`Qk)&;xYx>Y=0nj-1l zJV#p_DlRD6FlP6jwcXZxs^L6=Jf<@OhFwYFK|f%aSI{b(aa#NAAZP7h9UPgU?VI6a zf|kb2y6XFuXDrPstE$BvYHVjjlDK72$&a_i=|puoUeB$QhTqSjkFnLJ#@MG%(B zjPxjn%cWqyKYyx1x(~WZ+2go=4e$wy=4eNre$_rv*cy>lNsb<_ibSO2TSKS`1^G&k zp02_^mPrK-1#)t7*vT#+HuXi|=Y!Bt`5t*&8=D)KSQ)>XH(#&K8#aZp2#K@3Qd^^q z)zrJfkS35Q=&8Gh+s8bg%VX_l#FYC7NB4M>%knI?^TGk(=>}UgF7F{_xTllvv(=+@ z?OAWzi*%{@lvwSM@YQZp_E!ldddCC#8I7(pqCJpytG^5^UZ2PM-i0}Q2Y6C2>NI&A zM`vhBVI~)c?%#iF8)0tYeT>D}ir`FM(zhoNR9{3XX&3=PZ#B;x%caDOsI{8{ku968 ze29nH?2sAfLq+xcy2WPNgufvf6J=-12_>mp&3=E--WQ6mB6TVkA~j8ht5~4WW}tv^ zmfkDcyDZ*k?Nvg>J>A@G2~sdfh9t;<23RrOE+!!Y9>~05D*}3WgLDCO!FH4^K2N`I z$REpA6Eq}ZV60~ZN|ENMMCaq(71loIUJ*%q5`@~(N27G3==FR@$DPE(!F({BZjI%! zLBVl~NVy&YG*SoDT0aYTuZZx1&%x>exXFIzHmzl}k$0C|EsJL}d5r_g`u<1ClFm}!W5mg0>#jHd&8?cpW>mxD*I zBt$T|^|J`xvrEeeIWrbLa^1O(869f8wxxx}s0uOK&0ryK=4k(ca+XO7pwg z*pv+X-)f&8QFk?~)Q;t}1f$9gtV1V{2#cb!&)hmYcX1IV8_Rv4Fyh5t>yd9K6?-wt zG(5(*sd}_v%Ms@IN~4L{Pa-KOX~d1J{|h}5{biySOih<(#go#cOi!>T5-L_O5os@< zy|z2bxW!w@wZOqiTbWC|4}8Ca-54sg7-*e=IP5R01(X{$?A5N~cld-JFE7`lhsQ=^ zW8+cBYuSJjgC}(i?VVft+9l`a>%$OZcOgl^QP-H{>1hlApW$^aAQF*`Utj?1)FInd zl$>CORtCS9hNdvE#wIpSq{@jP^X7{cP%Z+9n| zUjS~aO+!PIxfn;N(i`BMr!HLcSLnXEnsQ9mTYEMst=T)9>(V9Z-fHrg&!X!d7N}B~ zt{f&JJDO9;iE0gmisBU-Aqw5{?Q?&E4iOF89Vr&GFZ)n^L%C`@4eKk3e+%&5=SdAD zU3Vj~Sa(bI{0z9^+^T2Yt(_uriXmdzz@ULdXolv>L3cO7-aH3>`+s@zBou)E;|Pyb zl?h^NbIEYq3_JJO1VjT$be_cP+HE75v3o<=EMEHonkEwkAB!JWerqKm*Gq^sOKNaJ z!ko9nk%w{BmVLPDgUOJuM2Y9cr>!)e5Jb#90f2YkK3&cc|En{K`b)OXG0{>bMP zaTikyeWz|*fr(Mpb~#+9ny6<&#H>ZUa9iwCyhSXdsHK$Yq@we^qf1v}BiPd7$Z_p) zU`YlZJDP9p_kp92^Wg&6eX-s10rb`dyNN8+bvT7Di5u8#mYY^?Y1;B;Z_t%R>?}U1 zwbf7$u4$MjU}eIFzW57mtp$pU^Lk!w05huTcxx8ronCbi0s&y1x57v8*xwh5RQGD+ zz50Dx+vI#ak5n7)K6&$|9I+R=G#>N+Z?;j2qaeX3^z`q(wTH39JAyS!tNrKfj3qP#F>=S@dF+pWQ=EOn%!-bo6< zoalz=NEWUdxtq5SY~n69ic3F;;P!^E6Z!W10AE7+;1}EMEJC>Ei;mt?GP1Wz+h=R- z11Iv*O3rsOF83iUQd9NNj0P)@zEn(r&I*P(iIGYQmjr1$@Y&DuA0p>3saR~e6_9Oo zX}_Of3_jma)9v4!HHg+jokpZlHF8*Pp813#yjGX#_B-L{t4`bkoBHrtll>q*V^f{u zsVpc{MLii#g@9HOJB!=gPA9XikiH^tdjjo)Fk9}{6|m@SI(t;CdM7&YXqOK&KK86E z+Os%RD}_g}pfN=(PCHkTrHyzqD|Kmub~4XCRjB6@w_ zo5~xR(B$cJK6|j?$OqYlJ79^A8HeQ>29wsw&1LJ#^=sGd3zhL*>K&0GV}h)CU}e1~ zYcSliw}DgS(|TqXvc(41Jz8=dmA|{{%wWG=GkMVG>5MwJiYPIFyf}#$yKxr*l0Re+ zh9{J7*TLVyiufk0ZMvHJ#5(4#E#?^tyij@p*4=2T4$y6qR{u^b1w0Yj?vAeCu$Hjqk6aQOcE4cVB09#n2Y?)mxo){H?< zY-)3Jt865xD@w}L!v2TWqIYHF2WeTb{oP*!JwTVNI;4d3rI!+cm=?_AuT#ObYyW84w>eIbF%$1fZ_G3(4 zij90u#!%)f&5w{{3AwzdN8z&RZkE+bRO^ddCZd%#T}4oH;zl4cd}q*;ues`{io{NG z=5J18fE1>c$=s6ca0Xc{Uwh+22dxj(XSO(n)S#xNGibFL0Gag>HV>tIB2~lhFmH+A zqtioXqN)d%fgY>t0~FQ^rkC6CqtBXsL{?LmwjRkA_s>?FeNl7S8oUXIWwH_of=Q`* z-1{-zC` zJMi<@iAkA1>hA0Adc+G!$clfZoL0qjaO47y&S5(@{Or0@J+vyweoO;~eZhP#Ua(%lkSK#ZIdQ1MX98bJWfIh% zI)p5*E#0@2@s*jfKshQ*tgYp*5C|C(@dh~$i;g}F>f)U2khq7E*;@8oyk+o%W&b5x zk~dOOWWW=P*Ugi3+}+|X`>__VR1%F-*H(Rk5?OL|zTcu z?x@)4U-UkcKCuoI7ffsy=~b@ZYL~!6OJ*idr=e!yZ}zBJ&a7Z(HvRDm#rH{VdmV0B z0vCiZ&yAcesOUT&dGGB2&?muOK)oENO^K4SDACaKFd1UhECz5)cXleV*vj->nbuj{ zzgl+WanfPJ>xA0%)_mzxanX}?)on>vlI{<_GW&rPv=wvNku1pZ)7VX#jZ41yj_h6f)XDtF?c`dT7l?|vS@!gTam@kzu4g4MNo<*tlZVjx&KO>t zr#A7lV}5&w$KakNJG&G`8tTJ_W zzvuw1y_x_UA8jt;ah+>ga6 z)-L`C@dUM5J-xWQ&E_RnQE&b~Tma6t4iwARs$tKJ-sK~?|K*EomS&!l*yS%u_%c-Fzqm+zP8zty$|qS5`qd1Pjg%Mk67(^*sZZKxemfO`$$(aG zokZfKdvzD>IR0{u-FZUqDG@tU*X~RW-&eQj&}WNfG~jCM=%q)UcNZRu^KZ>9uG;R9 zM&S;#`osdgUx}e=rE@*wc@@_Ioy~O$QWCNcU>aawwZ>_VZgc_B`J9X&KFvx=gYx zT-!Ne@(c9CbeFNQ{b?;vMZG zEAyR>Mqb#$J7jGiefzU_xAATkzNf%!_`qq~Ej;|9>HYnY*|SUfB>!NE*y(ZU$UW8S z3vof^AcFDBq-J=Y=P`M|=}(?ieYQweoR;Gx-l^nymU-X<_l`Upy}L}jUC=o23A9nL zVCnD_-5)!hIjVaaS86j;$p$)CcOqM9IUNr2R&+|Zt#-^f>N#EFz8e;Ssetz6V@fR3 z)86{t;BhG`wLWD0u{P&;c!KyR2iYEvS(dQSnO{n~T5#H-xCBZK+74ume80X6tO=xuJlCH15}pKks3_dR*lt z%_rkY5z5C;1(lFz_;jB)j=sI$=<2^mks*&(S$v)fb8XBw;w@b^`#sN-gk6Me&{qL0 zpt~^3t`xm1DCF(=GSXgrOF=D_notXK9aMzLqa__^fc#u-%hQl*FqSpH8326He(lT1)(%TZ;Fie&*>t zGAzi0A6eG~M*n4w7#LW8EoTOC;~cnd6->B}qCl_$r;e~fDz-~ZwK9S0U`;5kX!xj| zJ<`B!2V#b{c=vUL>wBXrop#}Q`Pdy0D5lf+5W}AFeRq2M^TCj3hTEsp5nHrgvKTNq zG_)ra@80^`JUGQm6yNjX^R^2Wn;x6%#`82WF3B0x*qHFIC+U9|`7WVTG{&Sp@BZc9 z9M=hKYifBt29WO2WUFyRh-b8U2llDh%eTf9RBl(cG%e+8vUu+kitSSYej=&vethmBx zf1lw-26hmpkA}i65^Ca!4cwOpLLaqUbaQ71k|P#KgK)kNA5!l~R~Xy(4qm&l(`+8N zq)$e0SJRa;R-p=p|5zwO13?5J6lT%;FvCItuBoQS#qoPSHufUJ>%h#l&V0dmx24?H zxyIMYJ4kP1rtz>Kvvn4Fwyf9_kGI{KiINGbYwESxs5Fh|TV9#7($hGNn!>bqy2kbH z&2aP4#Kqr6fd20K?&1_51ODB0%LSNAOwUx_kFQ158{ss@u?8&-dikCnv(041 z^9maO$#auXw7#EGPRFhcnmQ{U{D3kwWgWpnq2DNWTL$p?#Y4KRCt;hs1}2u%v+>f0 zowr1JRe90~M^sy(y6Y>e8C1TZ=eA#@G;Xs6BOh_|{w{$Y{PXs0I`osr_0b!$5nktg z6RveF?{W=WzO+6Yke2Z3t`*qHz|P`vtAFWxub4e=Wdfy}U#7%|!;1heAJ?erSQP-X zCVy>Gqt(|Y>FMcRw+O{3W@?8n?OeF;=XMrqbc1uP*-Lr?&-DYi))wN%hk_sp{u(2V ztd|r78s)7La9}rr)-LH2NVetq*oTee$d($D_WmS-)+ylSrz=~E^b(@eafAay5!X}N zWnFlsL%tOLL_r5@eC1|K=K%PEn_qCx>1d9ve;)AYZ`yRJ-l3 zU;1eYNEB+cc(m!WQqaA8Z6zV1J%PVGG)|L%)Ip0Y8cxq9jHr2`)&r?xhwBr=mC+@s zsTm@`hgX~8eD`Wo;BfeQLb!hYj)e2yeey{2^a}Z;mlmH8vn>G>d~955RdFzv)$)Z_ zFXWLPG;PWI?g#(D4~R5tPv6Dfc4E<|Z(9l3;KAXxoy)pSVWC1u;qRkvB=`=+-msev zeCt6;nn&NwdB3BzabUj`Mjp;Oix1T^%qVS;S6#av)5Xyu*RsV%oV0@_4yCfo+9(ML z{$A6Uq^+{II9;n1EGOYAd2yiN7pvYVMqH?VZ?mGPN!~HWL#D;-{FdE9!|lQ;wRaLH zJC(`OBcBDO54Nh!m#P2cmgEKsSngJq!d%N{E8bWsI(j58_cTUIZfq@?B|X)48B>$7 z+i5%=e-n&-euzilahQ)q$M0yj^{0tpHzGc<1xZ7%puM_G7b^=M!U2-fumnFQS~Z3? z_7pCN-PFz^3(d*rJIkfu&BFZFyH~HpRY7Aax~^ z(|P|QD=j0Jxt>>p5vEH&GUMqe7{v%8J2E{r@;(s*^C=^p`6opzcks)p(!iuicrG=7 zGfmtj8fgGBe|jY~0?|JpC8F({RbV*UZl2q>tz3zX_mFJu>f#iC`u3HNmQw!+Uq2G= z332KUW5~l=&H`9z>Nt3~@e?U{P7bc<8BJFZXkZ5=Q!{=j&1+QaxF13to$?)auzw)` zy*_25ms5g%@^hYw*` z)liak&@y4fAm(f((@MvW60W9waE|L_Y47bSw4R7yU{93YF~gx1@FnANd`(eMamd!+ zs97VU_+Xj}xs>lb8*Q^KH065cNGaL7`j6`m zNAnmCYtZgv$n~R%31r((AKx*pJmQhzF+d-5-Pp4K*u!luLYETlL@=?L%gaW*M`d%? z+aGE=8jN(z%v5yNaXULJkEp0wr(5D+KZN#STitviqK&z7|fn`RW>rM{H6PnwM_*G-PWVD)@&oko)Z+>K5p zGvVCteWenhCQjbE;=xG;L`idVRnf-i`phkb1Yxj{_tBa@IFU_cw?m%=TC>)20^;v(*dC zx@}SeFXwt+q;xO$k90{jyAS|o(v%)@i10&TPmoUK%~dv7@vhYDKnl-&{ECOCJFgzx zU?ww7RqQDt!KiuIakZ(rV@!l1>WP22k0MhXkN zthwgKFZA{fI}`~eFvZ;vc2SeRbC2;v%F2;h3(?SN(d`grDZuT&F zSDywESbSq$O`b`i8oaMI9tUAbxjjS(V=ypQg{8U&k;lguyCT?G-drHt2RUZX9kG2! z9k%nc(`M|jf@2E7b-Owgap-T-JSXz`;Wzx}`!D-z-GtKLeo>JNNhEWZc%0KKb_o%@ zvs}`tb8}Wrvw_#z_VPL>?k35t_IK|>l-o&;6^;&%??3GS{PM;5ey0qltg`uPYOgeF zdR|63m{1Rpu8#w^i~#qlih{{0-7!|t9NGE?+fE}CVmHq3!RW+<&*hjl z60%!VP*he@BuF3b4fi%#v+VsoKT@9dRP0YdS8g#Y|D5BgRS4~*e2*mW3e9f)O06}t zrNy(uMS1N$09O>nud4c%WH932(VZNQ*aCQoN(wp?G!N33%2{tmX!N$?yD7TY`Se$& z=>O_>c$XmjOEm_91p90zAauw; zX*c;~YJLj!h)EanGZQ@qtxZ@t!rvoYHV%^e@UNb;o@i4=$)d#FGP%6_!c`{&!X=^% ze!v??90P~1EI@qVsoOQJ)i59j_X}ksSWkopziq}E&#l%8bf@mm=&U9?^fGT zT2_5p7CNr2In=njt$tBdrR4>Eyp+dqovF;Q=(jFMCrfgv91$(oIDA zv;6NQua?0^dkWS+EOkASb}OPX*O6qzvoD4EQD!OXxdTY}R*+)5U+CiknIM#4Awml^ zJr%!gSRoFcBVQat@EWj(HA3wg8UmzO6K)~D@r#5xch!h}Gk-rd8Z1@|x9Z&BBMar` zw%tUT=FJFk&g%at;AnAElZ!Kyux4JFPMt^EYM9(k7dJ%f1U`>@^VMWw zKO7k?AQRLsXl9xG0Mue>{|#1XhNmR+Rt!ylWF zjb~#K5bU)tYl@VZBh7V3SKbTr#mqSd4^lR$W)!ZQfjN~vhYOhkVz*SZk(a?BEsd$3 zENxGFonDNlUsY}i!JU%j2V=--XOeRlf( z&REk)6}b8Q?aQPht}z)4Y1-A?fvL;ZrX_VvLm@x2_en?X?&sI)vB!GhyqgKP5RAPg zVH;ES2P*D=6|3_?^fRU_?6Z5u7EN0|ok<{_^N8xNf={AE=UZYP8Q;>ct$YmV=H}ve zo@kmmkT-TRNd>E$Ozcm%0()m%K5zYILcEyN(JU`79Bm9*`{Xz68r1&24@tW5i*Si5 z!lH|%MKu40HW){A$1VFEh}N>gj1$YE@<$Y)R=!&OU4UK9`k!i&v`SVuP$sf$@*_hyf8&d}xBy23?ST zf8~bl^(I$`9^MlE<2x~B98UczhqHpyWrhaA3FFD%@&+S|j>Rh>d8Z?bpex~sB8$ z`@VK@cl4-qpH@(x>Vh`r{ZwHo_kHPdw%_~8>Fys5_oWN4_oQ~-gm&CIs_7&mG$Q!! z!=mmbwtHhYWj)98aubC5vkUp;<+?^h0&k7(z&DCw@_R1?<5K0NHEpGv#2>4ubMFXC zvb_@jQ!OpU3d?(QU|r*^WZ1!{Ad<5o)p~K4MeOA-4K*WlJe{cK?0r;A2nIB*zCum2 zTwY~NTbX6I`R>4qjf7jiS2qgR)8vI|Q&qR@Z@~OcDU0C8^fWxQ4C!I~uWjp+E~zM@ z{B&MG(KO{F^$*eIXM;0}K91|lr`={3ZU7Rc+q*Js{x?S& zZjc$+$aerJ!Kf3Jx#xV$$ z-(CY;3hd@C%HNGQKl>u`-lOYLo|sbwFseZE`_8Efa`ZC>5k zxsUBo-e7X(?0=Y0uT(2U{m*KgU*?BY)q21Av#V&Vs#Sh+WFzL3o2Zk0a5N&wp3FrS za%_|FcvmY(Ht~pB^Vr51f*qnJ8_u>=+D3{_<-?yMdY9e%eDL88s4S#fPZL#B&6_DD8y}^FLG7GJ?lhWP=@w$v?n0HLNRJK1!Nd7?Ab$4iacz_9OcDb1nvsToA-#V`XZd zmv^}EAnowNnR-6!srbNtLX+}hxzb*K)}ZU%e=S6V_pxteG<>URv$8}>vi`wD^#AZz zVQL|R#4hJeOTD8@RE3>Y5dg@*2)zj88UofzC#$ZM!_xPu#r=}J#l+*k>q^%d6Dk*{ z=#Z4V{d~LyV-w?J6Jvz^wb2ew=}=gtTVohxeJom}VD?RTG|DiIw;K?h^t7%(c_mHU zX(jCcVD7Er+6=ZY&>A(07b{kZ6)*0jNbx{%hvM!8mllc_w?crFV#VF96!+p1+%+Ls z&~U$=^FP1)dLJ)OdEv`s=9@ihuf6t~?`yegjm=HZE?Sc`Q>cWqG7{1P*qA1)x%nA< zn!Nw>1)pVrJ~F9&4~O}tKv^4$w2S8XxjcR8nTp?We}8b~KUPmJV=2%5bls5&76RS^ zDIYwtCC`fDsNyN*t|4Zoi9{YXVAQFa$-1o+`8fNNg^8aP3$wFVbv!Ix^Sz#_VGl-@ zE)kn4t;Td0{9Cn^(a!*xwz;vZZd`7p@lkK~)YS_np)O;zZ^9F2?zK#Nca7Rlh*aa+ zt^ybHAI$S!7Ddoq;o6MEr9f^vyJ6&D#>R&q9)~?^NKz)=XgfQ+ew-u05lmM3rsqFC zJ;UG4Pme1Dw8z`q1L}buS>&1Lf-uSwuh543O;9Iw3o2_Xb*h#Rdzz!HxZF;8mwN83 zjRH}_^OS)_Q1W-)%WRxXvBQyy-}rMcI#wSXJ(+XyLRTa26#tD~m=IWv2!}8ac6d$I zk>TMIcG7&Tjvfk^HALe}McT*};NZ-{33LI6ZM!f1D&T0DF)VARx-F?_B{F0Ppo1QvUNM3zGoD49=s_ z)@eIM12VSzGqkOb`ELoS>BS85!l2T4F>>sS0s$afCM&o!7x#y6&)VBNdlNy%sA#f9 zd@_1~kN+GK26S{&jgAbl4^%5XJD(A5IMloi1s=l8Qy}Bw9pPXq$ny;o6UnOJK$P|n ztGzvkXu`{9JFok`V=H+vxU!bgw>0Nl8s8f;edeYK#B-C^n*EuMmb1E#|yr zi=a-Q-cx)E_7S!9*IhfIW9BvB*wy|+K;{uj3Y z8Xqx$WcX$IkCIuI58C>P@rmRVBVvQ_~P&k-nd8kegK3TLG!XSu$)P46%ZH5STEz6rg` zNh=KFiOGkFVrIzEp3h$Vv-i071b^jYi)-OgSNb6ZMz}Nko~*gt#iF`Xq91F$;3z+r zWRZz_aXH+_Sys!)Lsi$s#k(-J0R5TzmNB+Lrr{~%4APu{K2#AgFw)d2EG9vd6HrdK z_a?WR`9>nMgn$Ozy^hi+fx~@5sA;0|2EpdpxhbdiTSiO85ji0r?ow@~(R~?km7g6v zI}?tJVB=r~a@y+Iq;z3ZQ+Mm6+o}j!dX!(RWxnRjp6yxf)s>G*wAGJTW9RqI%Vn)i z9|Sc{>IPT_>uBaPUAbG;v^7-?R}p(#+q-5WD{uP*eo&byJ zo2f4y=bO(o0_t=+`L2X3GZ6p69OJC9$S>`?uR9lYGPS}7X?25#8ix9AZ*QFyw3@t% zLEP(@zwh^}iT2fRS~iF`frpSO<>Ln)o)#As1Fs6d<+kbvKdj;DS^UcvTLPfi%{_wK+IKH z_BJ^?%=%AQcFTIGL$o4LF^2uuU0nb}`*-$1biAW9aChm5t3g z5_%Kxe6!rl6m~!Ob@4-__f5%!v_1KiFU$$)-yWbl-sX@XLO@XlvcEum+4bZr2;aQ% z>z*+UHsB7lM98D!FMb%^hD?V?_lJNuiD5kgtp|ko+;#)swnm-?`{H=5?|Uk;45<%= zN5f-N1Gs)!-RUze?c}-=CVAwOx`_}c6Gv$#c^vEbc!VCZ2|3zA0Ig=rT`JQpedue3 z!Pjl9p~PFmS$0#TJyrT)WLA}nA6q!Yey-7@FZW2DB58CaLUj&Z#kj|-V&IISb_`ch zkfZ-D7(m*06Vc%&+{Ya&7;Mk-?<_GQ@h72V5@&KkoP8=}{*+xl6P1lqiOTWtc!vF= zKYnkYRyh4L-fQLmaRF(2UZ17>r0?dA%e%rVwa+LR#WS27-E9^Y%Bj6H?R7Tppm#Bc zT9t-+2g{#$TTsJ+3?kyZV^>d}MVo}+1zZ?#1O56|HnnA=f2!8d?RU9_Htf90(#Ks6 zc)pFkdRydwbDY$pG^{C&t5>#ZsG*l}pHgR)ZavO^4y5k|p!^+MxAM0vm>>Axbi-yV zcD!lYZJRYGLzIuN(0ImmJog>se$V}l_1e8duWKh@@9-*Cdszkmdwd%dz^Pr;V5<4$e&o?6NoWrn(sb!0t-y2Y_;0%ULTC=Hn%TOVV&){DAmv+V|c% zhpjL)nrsmNmF#tU>q_u^)4Xn?6|Ta&z;)7ftn$FKm|2~w0b}#TH{0&27+FPeQYeQ& zY_mzyWZ$cP>!`PkqVvaj)rlu|ftmF-qtkg%t$9n5U`9QVo9xwPF^PtTb~xTpHMT4J zOsMr*4yww}yXZB#^#hH-6L44Ng4h{<@z$?TS!_Q(7+;l$MqoHoNJ@?C8iCU0FASX{ zwQs9KXb@qz{doLF*Fk)GZO#c$pKp0=AN`cXxLq#Zrg=*#Z)DYgPfw0f(>nDp zM2yG04}Pf5g*4W=&{WcH0~;RB)1StC>wB8Xcd1o*d){AFUcz`xITt8>f;1ItX8CnjwFbM+L9|5mx9KeMF$=SZ{(A(-N(z`}!dk4GvPoYvIQjSS-8CjZ(ntn>kQ!bE=|I zL&*%Y3p+rm3C0K5-l-b?0l*e(b?wkcWmeXu<@n9QekgFpqCe*>3p;w5UM1R((DgWZlc+xC%h`Z)CZ zKDbFynI^oVa!n7zFJAiZ_7KMoH3S0t8HlbrO`-C5+(2fy-G6hijR>e}w3wB4RaFu3 z{8JdTU?I=m1pg&m%!ot5n!3z?lr1ocR8Fy0_N?J?=!MI-SpBh(G&Fs&lW1{*Z1Cv0 z=rCh*Vz(+xQy8+S*cBaDYIz8ieZfZr;#-v{m7dGU$Z#O)eyU;pF?{F?{hzmb?eDD$ zZ=X^j|1+b(fJ|g_1%TPyRXpm-`S5&(s~1?*CM$8nWg*zdVMTWC6{#bISR7EiTmP@6`8}EnZN8DtQfeVO4A(MdwnNfg)Jw+iPw$4o%6~D___Y}8oM%?Lhlmu zjP=e=8>@VsIHTR|<#H2P0w*TfwIZnYltRwiGB;k`MFn1>mVk^jT0&ztm+3nI3SXLM z#9jT3kciYG-5F+2`kvJ58eqyQ^nq}ZwS>gJYI3ZM4M;JBpZYi(;C7w&vqE_(^xNfEczbJ9kuG>rt4Pf zONFwjH#6&oanBmU4}WI%`#M40qo(u{5^!li<1HDoL|o!;>NyH$e=3SW0!AcJkp`Yq z^l=hV8j*C3U_$ zq<(B>KPr#WC?cs*NZZMnbLp{G1emC~&qYmMx`Q5XK6|8Q(Wzjfo6w4BtHovkCqayj zUfX~A3aijUOP6GbmcKBi|Qrh5?Sh2vJWY#wv|E@okir&D_>3f{y2QzYjr{@ zH(ZocJ3}jXjPD_A%F8vnK&zSPTG4Txc#ZGM& z4j`73z#DXsvtHM9uV3(2OFoVEgh0Si67s;tfZuu)6&0q4Hh+J&E7L|MA?Tv#N+%^sO<=r#Wq620*_$wDziHA6!=`+i5lrN|_3E*dEB4L%3@a!%YGzSoo) zl2_HS`y(ep_PN~wlM6sI?z-7THFsE13_8O9jr9pPW-v0Udhm7vp;vphJq4ec>t_sD z4D?*>4QxJf(XKs}!|7)X%dDl1wXm9H>1WgYrQ-Hc93UMj z*Jos7$u6y%!r3~y=zYfGq`$xMRiL_$&qA*_wfubP*R3ZMJ+aUa+8Z7cM#ViXJnTK!ZsKti8W&=5|6Xd?UXrF$+wE%6P_-Ms(37()(ftmg znXa3*6UF@)2R&Yp{EUi-yLm~^hlZ!22b$%hzC2Sqly|@Dos^^8N#>W4&QDi@@K+jM zfT>-;)*t(BG_mQoORe9CPu*CIl5iCq>YGaaswkP|7n9sFDIYUWq8uORsmjLBcA*{!8h-;no&7k+*ejnVB+d9D-VoZUR@%lv*W08Lc}Vt!E{%D)RM()e z_~k3O;zM6XWeTzGPD8V1DU^A;as3eY2fuW7fo5}c3}qpNVT19#i$RLU`rqCO*{a#C z8?|hX(!jWl7pbm`kD3%}n}7w?JGZhSp;%J*EajBcP`o&4Ta1tdwjl8O5t_@qi?~nF zNT9S^Ku@Ac?8(tc;*?l2F;3)MBi#AR94s@i+{C>)>3kUkNdmH!22l+wHh)`$?6ut^ zunw||47QiidCh~z>F=Mlax${~_-eo@xyD@m%W_4(x{vkOn4UR-H!`fbTaS9b+Hk1u zta=JQa+UysSJsusKCx@Tc@6_iG4ydd{UMw?;K&;8bh(E1?WaX?af>x|rxZ=GkV&o= zr7Zr*eTcvIP_x*oXz@UtDmkM~ViK@B$qD-9WinT8nQbqtX5yX@#xnTJji?bhAL z?SGQx!L$G^@Oh@;gPL>xc>6zC#`fswpmE9mJS#JE zTgK+svQ=~u24>t+$I4HWn~CVP*l|wn7=F8K4W&IVe8yPHShVxeYQC*L@pYib7dgcCor`1wYJdDE@(mP-)H0Q7 zIPVFRcve`vmLvk5bbDG2*e9Y-ssp|Koz%m|H}DWtm@gN`u>NuklI9`bRu&ah-0!Ls z7Q?6Ffg}=hyb4xPLar~@1R8E7%xt&TwR-!z!vYd%ZhAX&By*{;Qw}gkW27lmJfYz0 z?e;tVg?e`QtC*I%l|@CjH~Fn6hn*-6$+@OLu?vHQlbUUsr%R`oH-|#t5Fk~{yq%@v zxt_;B?fUeDkep6P;w#bPLpuPc51ik)jkpy*@3g!{X9|gB=u`hG5jISWbSX!9|;BEbJzCCjCGVdE7w*Sgj4Q-!5AN4FSN zB9N6p@z`-r zaeU8tg6n!tWqZ?Or?i-%$1k>Ub5u_68%svvir}e9eg-4XbtkS%0o)Dsq&6k_<%E_-N7(t!CG-j^w z+h0GSiN)T6ye-a)AtUycIHZIVJU@q`qN@qzhB91HM9y*}MffCd;lVPlWz!$iI{27e zRR)xdUUR|aAf=~~rlm0*@dgBRv!FhfPm?Yq8rDd-e4LQT2^9yAwW2GrRU;Q2n121i zs-Ew9(LoZp%r4?>!TXohss#j7i(iKp%^riu#cHb5u4ENb zd7LA+qH|=th_uvg{^1v<&hGjm?tlKKg1mkF)JLB#X5S5Y`9DLnr}*SH4%tn=3=Qq@IvzezIBoz|I=Zq zs4XP<-GH?*sRNAG2(W~vNph;t%ZzCp)!Pw(5_9}zTG8Auij?uZB*ph!f zUwm4lX!xl8^y3d8e8bqT$5A%`?{ejlsFO4~vDlQCnVf0?1=|buLjvRc1lj6ny24uP zc5$p;xkjCo5-v`jKcHmFdsXvA97FAhaD~d9zkh~f+v>oW!J2FCBmW`ouLn?>gdXa= zuwwGXJ7ln8a8@h)qYuUW&`j_*`Lp54Iu{RA*%%p^IGxp-L*;OKxYEBRh*SphJmtP0 zGj{8+9l!hbUtCuw>aR|0?(8lSXFkiWTV|!zXW_u7p7f!4(@*|>^8w`)UsDxJgii%e zr}h7V35fz8**a`=!E-v;r9FK918bXp&2DN4&F~JwUX<(ZupGTRUn8=t8faXzoeEiA znpw#EBP7@sn0sMts9e~l1#^EhU8YP&|=)sk77_Fe3DH`3hd zt@9jLdfzo{8Ju=iIvhp;9l~-a4S3+8IB|(J+-h#y+!Vu8a6^7z#l;Wh7sI?*ci6b) z^XeQ8=rqyytX$Qh99E0xL_zB4G@h_5&e&>?z8x~FY|A@@(#ZrhcsB07Nf6iejK)bj zCa+K=hCGw7V1c_eERnn0FBQ+AwHKZGX>9ccAC9+c8@PlR3@a*^(?uqv zIp#|l&RG&q5$}62P>DcV8d@et2R)^iJ8~Qt`5)YK|7be-qmmCHx^ga*0~Im7U>xyt z-^zNjZN*rs&?x5eNk4y$H|bFSa@Cg=&uTNNK0KQ2MYQUJ!(SqZxs2P@tw{@&*w}LL6niHzIX>q1@XV0il;nlF7o0baZJ~N7s zM@X&!^VzGAH2HlV#@@5P8&nGO+H}^T(JC)IQ$$gIT#_bAz9(heqT;BS6F0bDILCimzT2-ina>aU6Dv6oTP4d(3qPA*o>7DIRe5lDp<9OfH4zoFWFqd=J%)3Dgvd= zaW-%H!k8)XoXN&hhkQ4xd4pBZE{U?2e1{cm5TIq~B-Jo>w%^H5hy}S41A*f)(9Bl~ zxgp1gTK93u+q_(E8eM3V7iDmW>zu^v5R2%rTVKS{$sz;gY@MNvX%AkMQNxk+cz%X% zrT8^J)%Y{<^&WIxv+(E9I8@G?64s%)a#^K`*RSqov!99Y&lDg8Q`qVACIx%XRsPya zUY+hTvd!0v@f2H*#=oNVa=+_StZl)@y!_`nB(YA3bFGFeNWJ3h62G%IRiGcFsh}L+ zb%ERXBsXjvPceTGOOW1bqHkoBM2awBkw{HBUy)OYdAS*rtAY2@W!nT7Pn7`ciEVLJ ziGD7is@V5r0)!&9nK)?5#k=A35A`bPV=dBU8{V2T1N`d(hjXSP;D}H46K%ot1&jn6 zQcMYnQ~+h0t1UUKc?SpQz+zpfY8Cosx-Sq&ghP z;uz4cag1yM6Bb7?-8`izuk+j4IG-8MWo9uGK1t zUNlCt876btD#ungkDX?s>c>W6Z_I9sQjH?KPLf5STfz<&a7Dt^9ey8;KM6$rzSC6X zBz1eAqVaj%UGdU5AC9zjtcmcYJE}XvCZyWN{nlmAoQ>PX9BvtxAMp z#|!V;$UyCRRt`Ce~HrnX}Z{WHQzzcMVaq?(wT7Hp80h&at9?ogiU5a;~rA2URCyF2UJNTnR4XA5Hs= z+TD9ERSPX_U?culU)se5?pEf)!vei@DlVV)T#-a22x8#{9xdYmcP30M1+GRX&xO|6 z!?U{nc5YBBODc~S7~j?`zmBVcf%DSVp zNBmiXzGE|@+bGei5;A_eFYv>q3?H9y7Ho;m_u~2Q53d31r_1eWzr_Zt zs9@-LzTfxvCEh~R{oqgrZ?tYtY>rU9& zU_o@6DO9MB^Y8Iy-NSZAv5K6_yp5tn+WS@Dm-4}cS0aV3ZB^#UHeTP->Z^$M%1mhXH+^ z`Pt{&x(I=5-|M-p18b}KnEJ#|2o$}y$9VMl!kgU=#>F=y=*pr=O}!}{m%^?F9^>*8 z&#GCUK;I78I!)D_)NmDx%}iU2d(`UZ;p@8Ao6EovUS3hiHC@n48ocs+Cuta6&;yLma-!_PM&m@Y2zE#@26akz=d%q$243u8tB4(Sm}KJf z9%jSC2I^}0I{cfWmL-q1#XKi6@oqLF0->Gwrcz>Be&4m*_VTP?{`;MGEoaBK)inN` zQf}fhKM%#czmuhorrrIizPt3kj#-p9Zo~Abp%ndf?o?N`;$Mo(iER9j3ji;iTHSF8 zDZO1rtzpwJCfC})&(|r%H`2vlWi!sTWZ9km8uPf)0$h0foV8jK9Y>ozhttrbXwc2* z^<9AZNHw$=C9FN zqwah?#YFIXMh9mU*L4evtLEvOU!(KxTF2Pm^Z+sV6E{L(3Err_-RtDrAY;p=YBe>- zAoq=NJr=4RHEIZJx-ADYo0?0zrsIi#U(079sLh_l7|~6ny`kIw3RJrg>$@kv!Dzw4 z5^Xuu$#ErBlnXamBZA+eo$$$~d+Yr)SrPGkG!VAzT2&i4DO_(o50 z#+nbMZOX0{u?r2MRYg^{g^j9`7O%!bEC@yF4`^yQ&sMf>MSeC(^TyWUn-S$)8`s)6 zMLo@&4Js`vB-vlK)dmiI3U*fypd)IezaDpIZuI;I)sYe$X1#!$#jeP#)0DaioL|eCgv|ccha_hWKT@2#u2h^0T&< z>xV zt?m%7|5nSnW7iEH$gs&wQ@vge{Uc!A>ATyq*C50QaL|$M7CuzryXrfTB#kp`-%Q0lk)Cf0|gTBJUzwBW0dZ9i*Hw67fQH zY;Yj@4btYwEgrM(;t&c*8D+61&qY_%ITN)3NKdIWy8=^ z_G#nJ`Ky70?KVc?xhKS#Rw~db*1Ce%d1hHQzKhBQ&vO-71-g|oCo>rTC9PzCeiarg z3dJ)nX;c6ORlARH$;0Vu2RdITG1ovOQ9+D<3}^f$9Mm@HXvfxlq(pr3iD7Cduh$AD%- zDBP4eiLfuDDN>?9Yn&g+7}#J7LJXbiHq97H1zXedv73p5Up+ORG_HbD>`xBl&QQ`cX-gk3wT5Z`Xak~JjJ#bxZ(Wo zf1KeYeS=sEkYGZ`5_@r8u{XktuW;_p`ivw(6BQuWx#TJ8k*9~#tefUdn*3ZFw3vqG zCb7%ZF!rz|_us5gdp*g^E~%sr3A;ogtBK93yOcsAA=D7)U4 zf$Q+TcK`T%absXDQW?S5GP*L7S>87!#tf8|8f|}iRXN%UG~R|6P?A$}h~KLh zvv6^%wD&X+>FmC|an}IULWcdmrOLYYlrajF9!Q+(rWzOVc*eD0A9yy7>9xn&yWMo% zRx>H@WC(td;`=^8udc8KG)u&6n=7=Qz=u!*kziAx$+aJ+ykYHmCG*>?s@Y?w^umsX z+wBc5m(C(RLj}t{^nPj{YyeQ;_DL^@o`>$s8rq3`{_ye7KiP4Oi6?wyq;uG8CcgDd zEh-qO)Smr~MnArgJAF8H^#mc@H0pt?iYI_>S8DL?N*?E<`)-wiDiR=5o^{V9yu&?> zLb}Z)!PnmC-=KC0U%I|NtT>$i-D>I^S>H5ukWFAk;N$$ZJL1hjCvX=HAGt{oy6-U|WO7|FFZGlqizzBE5W4wRC}EJJ=yxyXrWD4cpF zfLB|s=;CcNfe*+htWa}ivA-N!o6b*9sz?upIXRM)kc4mHGd?|h6T-#9PN0VB-S?)~ zz3S^i^d`TLSi_zk{|Rmq^+YG?;~@lrD*<)%tZfD9_#b!u8pg>fJF8_j`O1IV{RFny z;HwCzlW$B-(jIM_B*F}VM&CI&m=DK7zqrRk7``&#(Wgf1W`0JIL71A~ak z-RIHJ=JM3hl`P=$&Qk}-gO{o^N8ba7Fzv9w#qR-oUdkqAWFtq;=$%(2JQiJ#Di`gN zbxU9c$cueob+(DOqYeX6v>qDHT{{@MbP+y-d;N1Xn~Ze$<~=kagXpGqk1jr|fa_A5 z^k*pLqq9?7KtdyB0~Cmx>Rw37?@__kZa!mPhm*PVMb=dwh#~E+G7|GJmD5801bo6wB#_pXl#nQ(b8-G zv5oWwYKVJ73neUu$_l!pLl zeRv3+P6GzqbI_^q&v^ zKfEA`YK%Cu%o-3@-3xq#T54WY%)1Rj#cxqfVG_a85LBO z&D>CylRn+075K)_xC#vKbx)bJIgK*Q>~0M6&QZ8AyC6U_AFJMtxrE)mdbf_)mo&uU z<<>g5JI}GO@V5eq(+?^^v;V%sn2?Xkc;=hA^(&Jm@hYDbe8zyYE67nY98 zxI2}YiGty;J1k!5XsqT`1TRk3JKmpQVmlYeNQy)r3RUOn$Ar9aA<~d6X^e9awT5hs z7+3S5_jv5ZQvO}$%%CB6Tx39ngrBw?XwO+4Ue%1HmB z711NY7@|cftn<+-xm!b9E5SHFm9qLxnZVcIRwb?B3DFw@$B1?uA9t_4m#U-xY{S~cU zu*<~AJ*z!s$~2Aj@zNa$s_-?#&n?7vt{N3jCW(z zZl_w97G6g~O*Ml&PvDxByY#uGI6%634^y(!;Ue@doei$mGvV6~Yv(W3%TrBLDvPJX zxUOq%G4&H)gRWg;Rqj@P2KPomh^Nl>c8ZY=ZeBWrBWv97$l>syTh^3vkd6rw{V1B?wtN|2zy<;sctGaP5`WTx_~J-8Gx zi>Hg7=EX1l;ofAW8n!6!ZT+dr4)1>10j%UgqoW9N?!&9GMLy-A;NB^i!SNh=v(suB zK2yNqiB8bmkJb8RfMuA;;@Wj45F2&ooFL8#IWGckL)E4wSq;1sdfk9dhad%M`yExO zoISc^3+4L=o2mcD1uW`1kDu?Ai+T3D_mcY8{SjP^p(Qu2UHy@NY~1ecxy$UrWZYVz zLgl$AC7}OJNyt+zr8c|Y==$mqiABQc7{LcQzBe&K3b@l6bRt?Z*l<#Y8m zv5NjbatCq}g-Q3}optZ_T5w=PqfN(IB4PY7)$dZMZV;^w;(?8xw&CXe`hIU8FOVgl zxCNB^B3kwi$f6@VjRP8u7)Y9%Wuw+f`V3?nW3${qA;Cxf{dny5geU>B`xJa;yky*g`51k5{RR7|VqW#i# zEVQCsImVT5R5IO{*Yo#7JS7~Bsm!IzMOipMBXCdA?={_;gSxB4{gm?lNoC)wB`@Rb ztI4IjlhpF3Ehp2oEG{stCp;~^;DDVhL`PPB22E=DXjIE($g{0ZO^XuD@-cGedRa`_ z-_I`eo!2yvDNz%LU3Xxrlf24>qDrE)sWCF8pnz3gWm+i%H54v~$Myc`_RraJ`INYa znUhLK_PX*hsGhF!rnqkJdimD$?h{isqDBW(Yxf7)A|%1!W|RoK@!hCLGBheTyQR;* z7623-CAB}Ztj1yU&6wVA)iAc$zpP>OkD`bhd@iq)&fCb6#;MSLuC1B7{dZdjnfS8$@}@UD+Q{`h;bcwI z7dU0CeQ(u5(vYtuJhmmf<-=GE*y`GTik{{dAc;HQcKeb9Vs3BNU1mlw@Nf6B-$2Gv z6=x18@p-fy_nt>Vb(cda3Y`Xhh)#RELmeT@&BkQZ`7A;$JJ95*V5io+aR1ab;7b!A+ME>9iKU}m4gb`b~+x!BFrQor9m4v z!~WEoqA{F-i)oO}Qyo0F74FL!dq}x%Jo`@y*ZRmy;Q{~CQ;(RX0a$9!t718jj<`z? z5uOfGtBHP{x9~V{SkGXXq13Mph(AXK?>|d@IVJXO_qQm&6o9;!o}ryt=F;&yH*z@# zGa&=e;!~r)I5|hfbW3NNeTs{)kBh-uj9-3?OQR28S+{MKeW^sz!p}E$m4ZHCOFQak zzveS6pb`_UbEyfD?OK7-JY^^7+AiCAvXP!;RuE(3$7Yb~OK-Rh`gu2(54GC(j9HRB zXH5M@QART8=?09DvaBFdOq~<&qZRse<>iZBp zlKp9sCMOLu|HobKGevG8n9Ar@;ufjv&?0ROjmc;14=L3I+zhq!9qC!GnuHS>8LM1A zC3xy&wEIAnIU&!->!l^F^^00-@se5x348+9^c$~vXzT0(?@C1yv1P&{JJKhEiI@Bt ztwVy&#?xxql|LTE(hxL4c9+(3=&mpKsy(Pno|60JO=+gE!#~Z%8X3qJ9gI=^!ns@= zNHPz)F|JP0t}ccB!!-se0N9F~Z@!DhTO?%Vk^7@@v%glZW&TWj$re@VWcVSZA3n(| zvnc=9%Q~H@{pB(-VBuBAw+CTN4vTGSEM0p=_R11dz9br9c_Lm~oi@|`acb8clD<^G zz-T{TX9X`3Q-th&$0X)QN28OK?7cz4X=vv*@KzDZgD>KcGRF%p>~0{jm?2@T>-^5f z(B}lhw{)N7@}z3*z3QlXA>CDMbJ^xpVU>8?e5Y|~)$%>N2?_Q-{&DaIF)ctJFSV#G=aHoCh>hybA3|e|8hvzhGwY6&%{1M5Q0-3|S&;Lz4)RDee zc5m;#hSu-{k!+IYTA;lA*xtiXE=*BhwrmS zjFU6;4*e~H+7r2?7~KYmUOx2T3SJi@K?vy(F4uqRRaKvXHtWYmRciC6#b(*7QWfKf zG$(UJ6nd2}81sk-}>k z9F5D;NLyH9zQr>ve@MgXosZey1bpl+>gRlk&B=j z2gShwnxFn#p7s;b=K4&^wwi|XSr7(WrdrR1ull_oniQxdk5d{h><(8N8-zcS#XH%z zALT-Pd8#JKo8mgQFa!|SG}e-O$P_NwL7@45o!BM=miRL>Q{I@=Qe{NW_Quf|!`xh{ zx%{JEOuFIkZJ-zQUMho@YU9l>4MzX?Mq(30P#be;F1zt*9`KZ&UYqB2j)H2F7bCl^ ziXi<4u8A!hKlM5=#d!wdGPkA`!l1RVt69a;)cop{>!b0gHeU4azCx8oK*+RON)m;pBC<$YgX%{_a6mDw)h! zDyaa5Lsuh*WBb!Fio2IZ+ujxz1zapF$@MLv#l~MRq$|IaYO>O-e>zmwT01uKp$Imw zOZF?&eJ|+};II_)PA?-!uVWvaw+%7Qx_fINt_`t7i{#5861{fa;UUyx8C2a0zzgNH97VvivE`Nk1qS#$^ z)^?b4m{MIYhv~HX4eL9Vqw%4REQd$?P6$qM^;#c$yAl2};J8F%s*R@R%+*40vqT-F zeZquCHqoZAb`72oORD z!GDn87J|D42oRjcB|vc3Mj8o0f(H-o+PF8;1h?Qc?oQ)0F3oiEe(!uUb*rZ4R!z;l zQ`3K)>a**deb!og@3o(`_IfJe+LK$O2yrZ#UU-m4WPIU6durWn(nUT_e81wGr&E3; zAVcEfVy2>ZmY87Ol*i-O4X*||(+7TJXi~dhV&4?cfep?>5f$B-g_yM%WszMk*)}s{ zG_$<*5t4j9Z@BGMYLLjN^Ps^-km}k=cXxJ+p?3|h`(&B|J1su7qkPRkt=qaDIpgoH zFFdbouC`Mq((4z5yX}yRjW&4T(^++O>->{(EKkiM+dQixT;IXj>nrpQ<(Fwt`PW2R zO+(wCV%GS>^-5gR!{P992oJKJcwqe@`kzW#ODcXr|jMJ(+G`zLFw02X;LwC*ZgkbrXY2WQO zsx+hOlR=AFpuy#{Xl2rZUjZC<d(+1u3kFJSY37qd571plW{TNgcK z=*P(dIpFG!Uk17-`Zq-|jd7rv4V&}wyP&+EE?s#7Ier>NU}O3P=i$Ac z1E^Q{%_u7g(H8Td3oIq5GCzAhdmzaAlDj%THNuVyX4<0WY@G71mu1qX6EKI*4j3}X ziE5L~&B+V8G}mz`fNTarS?O^;Ng1Y*b6HxccQx+9CD3(*o&#h*h^xoz%IP&nm|`h< z7c6k}-mG7NdJJjzv^0I;rQd24^Zi)|>I!>4k!=?gGtZvgAte11^UD@@i$kVPk5Wn|UVlO&&tSnE(b?b>2H`I)pOpQpjhe{(l6F2UIz`eN`-_A; z?9;TnCOU)o=Q~+rE=^WrBV(HuQc}|uDfChvI8sfpIrrkX922YhS(lleB4>F}_dJ zO5cq!c>1jFDt81Kk9xkX$g&f>lsB+3`KcSrTm$|3CPX6OWZhNw^zQc+mbZ^pewl%- z2lwBB;AdhX&KN1c=QHhk>|e=cdzKub3$}0JY3_RaoQXKg{U)@$Zg&^qN+h8aTXYt- zN678;34YERCd%LzLLz#c48W#~zvNW&dgQuKO1uogt=mSdA~p&v!FkMZuYk)Gh;rT& zJQb>DseeeiQS*gbJ}zJN^91DG zs`%Q3(quI+;^c*@_0VvWk=46CmVD{7^U^+j4Hril)^mK{-2c2|!fOMU;=C}pSj zx&?|Od}+AaE^heeIFq0Z9#}z?vLnlWO>jAXN!AmWsSK9Ui!;y*7yhDxPb-cpn~Gct zQ-9o3z%m0mWS{+1@il3!(oc7qD;C=Lo0xai!`2PRBe zTltG7{>7rnM~mw6CH%%-TQj-%clYQEh?thJFmPDj-f@lDns`h3#4l4#zyFa{-pe>v zbX(JQ(Ahlu^$OE<^0IJPV{=AUy5RKGvRMF&E~0J&Ug<0TcY!NKFL`=8-D5eBDSNh% zWY3;|x>0|`@hcBarqIxNN_54|G=sk*@y$#TWlowhrLePO$vv761?`^Y43< z|Ife%db0k}X2CE8oJ3fY4sktp__W0=&{VYGE!3O_ByRf5e6~cluZ8^coPW?Yu}&t) zaIs7Sjl>CthOFpy!tpqh!_OXhD~fA;H>{{051b-9ThiDE{%!wR<}ashKN{6P8F<>@tUy}8g>K#_Dt~b0vV@(0gt2}H$2w&r|E~VzbliuGYk(a z`U^!@L;-@^ClJ>9sc=j-|Jj+^e3(YVKNI&|4L_67rRykk6c$nYWK*^x7uEEv_3M}2 z5jTqP$N5!jWc3B{KLa9jT0$q9GnV$r6}tK$Qxq)QALAKi6KhRpHe&@=Bx+1blZ%HI z11^#1Jhyk~%>L~a-{{V#HWm%PTI2rnB}qj*Tgq}8mMx~L-(WE5+u$Yj3|}VRa+sr; zo4(I%*1RYTYN?&;HlyUVuN-5Vm-NXraKjXr!YA2FtyF7BZXC<|u&Gza)-fur>B;ey z_+Op;>QaSb;gj>2-S&28U@S=1F3>A9Gj%V#4=XyEhTRn~>GJTM5t+t&rtlONT1yk596n;Gs+VKtHwHG;%4JM zG|~-Ij2dv)X*>%l{=uIL%WkO2UQ`T}Kagvb$gQ(IdtY0~7GDlm*#10NpVc@rTJe`t z)X&X~X|DdW!s4&pg4C3jPrjbR#qo?+dF{o+PH0wEZlli?#s8Sz74MS}(H+aS+7Cy^ z(w`r3ZrE#ARI>C|?`c5~fop_kDT($>uk;>@QwW@PhlJ=PI^R_F@)TYW1;1~cclXY2 zovte4huK_ZeGi$sC|s)iEN@CYTxc+(bDp9n}OqP!XPmGY~^ zLAU*<1^Xs@y?`I}UdYjQ(%8v7=&_SX3ZwG|b^CbE(s7=XlqG5Mi9ZQf z*nE+jLBfWKN~_92Q>VVq+m?nL!PfFZZ}NN3Q^F`O4(@S7{ta(!0#h!Ans5@&%eUjnD~2Ecu9+Q7sO zeQc~{t3R`4g9Tfp-e}8|2h@WkP39nrT`(>$x#1_2-?RTAvY^ zzTloAUsje^&-p-o_JTW~xI9_`_>-y1249mNq z_C&|uV-jVZ^km7b3~VpTBzt=PhJ+m|)zcQ{+h1OvRtf=8}aBY38ir zSaS6^awQ&320IhK2bS8t$=G{5NBF6=fg)Kpt*WI7@9D3%Ie(S`A8aO|xp=r3SDhRX z;U{*HT;+LDfo2GOJbZl`mrAM@CSAqmyjB%q={Z~>(jooqnFRwYB++K5cmb?mtZ0^) zLTtm>CucX1oXMmhmMK3oYSOj&gigU!`^K5fU&6Zuvt6(My2ldVt2p*t z;8$CS#1ElVX|f$)tXoO5WAV^+c3fO@g0qL*VSnY*Xd&_12|R`OaClHM=%`FGSPK7d zIzj7$c*7_W1M5j}9fSM!&yYKR!k*4j?dscD3N|M6e!}7?Lflxs2?4f?;ORHrDjtT- zW)YlvUL5dteyslp#8|+7M5TjT@U6eRL|24=1Qi@}U7xQrCh;satVSRgj7bn!t#&yD z9~vO)PfR>F$_=ls?BdgrCtam+qg7*`A2*ccC)fGM;0%Vn30QTdPmg&+I*R5AyY-W*-J$MQR`JV}xwxYf z0(H#TOdn1jcDGNw*kI^$gB?VOEHC*rDqEM$?w>(L(vgA5g=%a01mvtus?Z4qt3DBFHMO*R-LTO&_WOhftV_i!JS3wu19g)ta%B@NF?_UAqHoLUFXo^ne(K_NETU7D8pL^4D_QIzeh})e4WzUO{-*E zXv;6js(B|^-VB|w?VW?V@1Wif5Ja49U}*;mguM(A!rOy}u(8)?HlEqwUAbLd^vja7*1z97}g#pLRTxEaXlJ!+9b5?gC{s#bD}ErC*H zi&Y|U5bcEC4sk_mF1)LPfO_yK7_xu-!dlKpaWc%+bHDl0IiJ+6go1MCxg0r&-psf% zBDaK*C|Pqu^KbI)Y4<)Z5_hx2hI%QUa_*>uHna})B=lyHu6VybieJ@)Wk#R3`!HvS7SRn=O6hpDHHH)8RO_6clH#x$ODsC~la>!hT4D7! zl>O8Gib})zj>lYg%gLBKRgn(SGi}{PLf$^YR9xw$=nL4p3#%m)*7DikzxD z+mMlOYf7087uS0Gy*?`hcR@EBWeb|qa_5LxvMG`tFrV+vtkM#!L-6Us{>pmOdGykIV z-&nwW9HNc=?h-y$i}Pv-cj0am+gQE51jdl#c{EQY0=7&w>asR8jY_F^SksiBYW6Nb zCQmIcfnd7p=&Z?jj|HpTe4%H+ruo0F|F4WE!hCFAK_z!&NX(v7949x9 zXXI23?%qyqm;aQe;R;Cv<2!opcX#!W+GNKL(%3HkdtD`~` zcD>R)v_ADQvutPMUaiAHo%I2tVkx_Z>jZobPOV4XBqdYy8U12RDk8 zFPTv>pp(y*69Lf?U*9HXejWGpzVLAp_uifvlvnyVVbz-a4Zg)oBC=Jv@}dW~CG|f( z5(iz8BQ5Lm(q#B;wtoQ?w^HEeLPiMB!4Me-ykm$?#Cxl`(m~^(ZLpQa=Xi%bSm^GY zutlfY(0hGgOJv~nd*o#BgFwV-m%VNLbZ~X7DanU1uXO6o($6YM$%gi+ogI z{@qjg0KBNU=K*5(eo+PHO)mkQm$j{2EXfixL%SX=}f4_%#1{~ z1}LNrFN^2}ni!oMA8m@uNF+1UzaH5FFsYH?W$78=CFK8@PN>(>nVM;{IJ~=P1N)+H^!xw zVemhalc-u5Qf$HLgPAu1lQ}|70);Zh<*+ATO;ddNwa}h|*xbV_Y@ZWE(^nsiwy!#s z5v)ATfug-w6Rn@iRu&-Vyrqn~3AY0Wh)h^C>)JCP6>nk1vITIv9_LwLQz9jY%Wi?K zLG`C9jlq=f!9(0!c2(z4nG9`XR7)4I>EGJGMVp*+!MS}&WMi!+vY%AkyGP0w>f(Fe z%QvHNyZY#w)&00G81~FRi4bW=oGO_?IdW+c(>KSk+oVOtSf zE%Srt(&ir1d_X8xvgh}AgX3GtSke6#lq95-B!#i_)xpCN_=b10*2688U7Ht~2)xq-UYO%ib zzcf0W|F0y;UcR&$h_&cS%=kv0`8rN=Z=sfGpU3NGSUrjeE`4K3E;o=9&D=6NTD3Oc zpN`vDqUSthx8+=?;TPt-eu?Iu(`CQ63Lo{Bo(modS~tz?e{*o79~CB_ zt*)jU6EL^Nx+HWMwa|rND16+Wd93E`agNe&s(v_lH6#4isgINQN(lWV04*97 zabX!cSXs!%rL^4>&JwJ2Yg6p#)drm-3H~%#R{9RW9;s{yQ2crGV?@Izq4Udhn+z!BBao(!^HweZXG;(lYb>aEV;FnY(?*tUu z@RT^L2!cqQLM^YR=!>?$svSvRL zo7cwn%lesw)0ud;h!ij|ck)ip?6mJjT-!s=i~og+pt{Z9c$|LYq4kK+aR87+ce?X!n&_)zvK zpFH2@*?K1C=#`3nWqR!IpULo;g=V#FwX|@LDU`YW=>QG0NJRq;i#ye}|7u`pCe5Dz z_lk-_pctdm%d9NT*|A)i$hzU&4F=m7x|VIOjEk_ux^XBnW9c_|>oI_Kjo>dA9J)fv zL-N0JLWd~${$8Lh#^GO*>w2^NuZvKbUw>gbH1olK;%onwGvt_b`Plho8^Cw888dl*1lk3M!4HP4dIIZj>2I9v*OfUR!%AsK|bb<_J z9kcu}sauaw$K3p%9$?@E5cCTR^E? z^()$%*O=(j{EW<%v;7kOk`Dn~-=Whb(U{*Jq~wPy#{sy4pt)K(lgiP6nimSXaU(Ik z)$uYg1|y?-+)T$BYMrn_RWnw?2x%3}k&m;>&(Zyv%z1+D&ufg}c6AMnSMe_`XKWu> za6w~U5@lGe=j^A5iSH(+tr3d%%SLzS1ZWOO4%Jg4z3&v1dLkB^NrPblBsGj1 zIhp>KwV=aa|Eo+&?Fm4P*@U$|L5GDXeuF4J{_K43aBJ(3_<8Q=M~oPNU%oW@8wEU6 z(-`DnL1?qpA%-lNF)^Pv`+5`@s95sG&$@o~iIHOkWbXVCzapgueP~Si3VK{v=*yMn zWs8#v{R3$DBPan6-7gGsBL0GW;ICKP3FVXbskZ!6Qez-89>*`5X)@n*qwMru(Pm$_|%NmGkURYph_((JWV@K6Q>t(lFqgz@D>h z4!&A@A#5Qr$d+xi%9zm$(yx9U#9B&}v(;NY;A(!sH%(fhJ3k76yPZ<4NUr7Qo02lH z8|`&%EWcOEJ8fzQ3WA>pI8AN!Ve61we(&oZ>BoT5pX};yttZn#hF-(Z7pV>UhqcsL zCMbdwlFqltZls?AN)zj)q{XG4M2P=d!J&DiGle3J#>dSE?Ne+X-;Rkjs8w$|8M5=92f&*{ATJjnYq@A5Hm zqQaoI%;4;$R%!mZ@}>~vWl<@cw4z)r?R|YXIO}k_(ygV~Tnxg<%&hdmB@*6vfO+}O zdULXV>554vrX&y{Pbc!eEOC|hnd{}UC>yHh=&_MV^D(>&Y4G#Hca}JVghUtUtVe#% zSENJ!4+e3LiMQ8zr#ITx1@8-1A17I`?OrZ!+?-Gkq43^&;5t%XC$T zz{v~n1&wx5z&C zs>fI4e7(Per$<@8Jc;){I(pX73bfKkddagJV(9zwTY<2ThmGUxm^tq~U+&b#N_NDE z8wLMHbc?9S$!NE5_sUf0LalY!rg;HSUc{kT?bAcQS9DS?u#=##t#-@$*&^YI;nxfU zS!cdJ*Cr-Qmdu*1EhqT;_+=huiwP_P(AKiFdW%>NAZj;x80F}dJ+s-T3`mZuyExps zqlP=K+E;7aTDg)5wAQ^eKV&`NJdB?*fR(piF0~q5o!2J=yNsEKD$ehaDb_r&+x6P9 z$ord{U9aB-!LyAU?qj(mNV`3z)P($7;X^yd?Tg^Nj8cQ^0{ITtq6-oJqTsqAKjbTK zQwr3&AjHt?d=k&g&})-w4v8FtUGMSk*E$>A>}8CXMKDZz^~w@sT=EnRRLC4O32D4U zRay5O|465h_>htvv9rLTw{C7u;ovi^R&Pbb&rF9Y7B=4f!LlQS7fgx~9QB@ZF_u~& zyx9?5fpmKDjXlXLHL$~l(Kxgf6-V88d!TVuPHV8krQR z7E^#Ug^2rJ?VX_8p6E%C&r18HZsl2XYtS>;al#748=59u*lxFo>Lj~YHM#H9_aEZE zsTUJ(q2#hd@@jCcw~QYOaEnN=;glnEvXh4B0~8$~GgdWDs3sYj&ajh3f-+lQ8Z!29dj9{1i&EETZt%cSRErB~yyxLrRke3G0obQ;!{)6Eq z-aQX$iR)WSqkDJiPqmNY3JM}WD(;K2baOoD2wKVx*)NXTzZur_+`ByPIUMuxG(VV# z0Esk##{vZOJ>4&3%rXaK+OH#1#z5+u*Ln8dt~FbweYV@Z)b~e`l3myGP2Gh)PH_4C z;*FA@%CvyY4M*(Ys4>uf21BIp<@sce8FF#h;GRYBEn{uRjBq8aXBjmX9YM9vXH}Q% zgRs=@u;m+k zPHF;O^!cJFuNkd@Pe;Zki@W&-XY1k4Tij95ek_COzVMfb!x^Aqh3m%L?!~5Vl+QVO zQzCq$|9iUU-unJ5QPq)=c^T6*UE|%Ck%6F0&79v;o-qwp@f+eG^ zY~EI}%E7ozYtg!1^_2ySX|c@VZ1)gsJ5ME)Ho{>egR(^frl_~uZC{Wln@ChvIeYGX zb1VwG(+Y5T&9l=cs?=Dc(K6=Je10tGn|v{;)~dUE$$dOaAM6_TJ*s5|lriXap+vYC zFId#{$@4TOOt4O{Ri0{*;_(t)mw^z=?Y+TIP_8+4wC;1C!Eb5thLJXP-qz_J?h$J; z-b) zT>N&O4ZHgLvBSdr?`eE{bcb(uICuIq)34Hy_M(-fcoaOc6fuJ7!qglC`p?X4!S!Xw zI-2@`jO8jCZ5&@g9flXHf>!#JxHa*i>sLlf3&xn4P_AY7q7AewMG11aJ^t>y7X)Wh_snX#k3311w*NnYY5V*&L>#u0LfwXJ@U2}@1AT?F9ZB0 zX@3nb8ECUL)8jMDzLK4zE;5f=f2993g6e9WdCUtEd{8;z7DAJol}5h&N4BlGi&zXI z_Q&s0NmC{U{r_$=Q1lQin*=wm)!yGY5AA=7A+~aRcMHT|k}buH!wh_{66eSB?XL&? zKnI+02 zrsaCJF{jx}RwqqJKFX#8uwY)7bO8GC@F`mh#jELQkiK~RHtt*Y!y1l1at_T7dqEnG zwxEFb*4kozopxjvkkkF_2bk)wAW{qu(a-Jp``j)fTCPVJ!AcdgHnYph7@7NHkLs(Z zOC_io1|NHxY^!Vq8-m2y7LrO|s0>IyB>Oi3{mf!sCNgc>g?0?U0 zP7gq|`Fo6ze*;sD_1r&4kk@|Bl7YxV_9g8v5++rM345PFbebj|VCjR3#GN{(LtEY9 zAw6&K_Ti1mcMY!66G@13eX!Ib$k!ng@K1H(tGK5TEfd+H+l8V&nE`6H=4jCkWYT!F zgSP`W?opLY*2;lWh5|Oz>k|&O7G|4khvl;4O5~#aDsKQw;wt`I4c{ItR?Q+Kb)JSX z&tI6bSD2a3?r#aG$8Biggjt`fYHx1$m=XI8R})h%z#Qk!TaxGqUj9~rB%)t5o=xK+ zmq+0;w?D9T>thxAaV|N*t_)#OzfP1eJ#_X(6sYB}C`?@I^_<7K?U+Ta@2a*(L&K>t zY~DA$D0g#dZ&J;}L)$Er2#h85#9$c@&pl@^(-H2U&9b6tusY=`w^!QHnT|DMDLV>M zzyc=q{>>Yl@!ffJcSWfG@vWge@nKfTg z_2WL(OY;4P<+jbE@mKh*k_f{9lf&EcVutW&&qXnBijYBVHFVcCx6vJN1d{f}2f z_CqdwrWl#M&3o^YnP@RD6$49)`{=Z;<0#&YiKvzhrG-7q3@{2sk8L6|gTIM&O3HD) zc^-IE-7o(%s$8@+7_Z)qVbm?{c6oc^-P-IF&!Lby-z81Y!lichg*X1WB0+FssVJ#{ z)oO-n?%G^^*>Ep&^aDJC%vPKAngRa{-^*i!_al@0UM&%(OF-xgAt}kPnl^P0n3Ze| zcgmUS;sfI|puAO8*S-b5z#B`#yo%?(z?j^4x&5vUkHGj@qmyBY_?M2mHa%KntXyQ&26g-W%uoB@BG|j!i>hVRkp1~#}ZWt`Ao3DCn_>6 zr?>Ko9CBm4_FyCAu^Kmh*$bgmOe*vgK#ZOO4%E|UlaW!Skr|kt2r4jB?%cbx^LYN@ zf+Cun+Xoc*g^*eN$!WuPWj}46Jyt79t*3`efuvHkQtMru(x3ZqteMOPhdsaqkosjm z%#2r&aU=5>U7V`BPj%Hq;A(zpZbYr2skaQW6^n)JNowz0l#otV^$?F zHhLL*@$hkxRzLdtM{s4=h$Syju(M^l^Iqly>?YQ7NN$k18U94z)}8V%K~ zYXkQMA+m74O~>_4_UnC(d?>6rM)O*IiF70&rB(SWaQwk)oK^mLIa|5}WQJ z?WAaCD-ZSexjw)j=*&+;iimtpq@nvTx%d1y+RoDf{3J~C%|k##x8VP2_@daZDMu1Z zdqJ8$nub)eEUFymmzP4-ir$aXa&G&RxHp+_nq@!heBm(jc@eu4)|1wd@%&-%K?JBC zHQ{GvPy;&4i4At4ayZM;MF!MA+odz)UQa)JgaP0V9c%D9ND;W%Nvx-2eNk#)g7|5) zFtID6Wc!tLx*^FXSz_Ns$b8aoW0~w?UdVI)mKBO=!ObGW%bLM|e2vjkM(R7$lN{D^%y~}=?n}H*za&#ZPMjrl4F#TU|{Q2 zZ&J)OT?EiMinXGIPgO_TA_g|+IZmQ^$37WE(ArNW^YSDGw;uIv4daK!`PP2{zjN*$ zKWQr-hb`O_Dfq`f%X4~}=ORD0-b0mgd|BG@M!_pV__zlP_oHG8Ns5;9hzlvV?REGJ z=se140pFUEwLb=S5-o_Tpv&0};15H&L#Lt3`CjDat)-##WQuALxt6@Y=#r4gG=3WI z`xgE?nw%H0YO$W6QR{$|fE0PaHb=B}c0bE#B{4l+WYu@LyubZ_Qd-!pyY>QwIzzPf5B7S`u5y^KQb zH&(mYZR>4;UJD!=aF+_%pU<&%vq#Z)C|^`nLrg2xFYNhwFQ1r)t$6|`(s3`SFh=@! zVm$HprhK<+WAE$S`@-Xz70BFD`7U~rH+uJ_qa9BJVp|)SiPIxyvPcj=>7BPj69ao= zb<11GCpRz~Uq?A7UuN+tQl{hz+Sg!5sm?oOX@&2k;OSf|CW+n;Ihxp~9^a0SOLuB> zMw}U9ofy{}_}ZTVO9jXxo1jj>@2OFp<2g9Ic@1XzXJ4`x)dw1f1Zg5jPkY0{s7Q)M z?N5jB@55z$-N4T^E|8m64p64_!f^&f0y{t<5WVO=-siF!qfPEGw7u$DZ?L^ghm^t0 zuf~daad$xD^9qj~jd#c2U_T@AL9*tQk@0P(ccr{%X*YJiXgjV(SyvtC3R!(bbk@U` zU=F9t^gNqM7i6^E;lrj8{m z)NiQBePvzmHKQ`FCljfJK~1;24jzt_dX4+PO=%Lp2`1qtGLtC(6QKyHe65fWKD*I7 z$iwZivg%bv#do<$7G%hp7+AoYHuF#~0Y|ADtZ1SIp8Pi!Fg{gTojPPh_=-l8Q#-c4 zqftkUcg>L{u;TW1^i*6k+^5D2w3Z+vgZuhI!xL^yHYv)z$T2%-_gd&nZ|4?lFs~u2 z%aW^+q3?QNYS#_8Q2l)NCY{(&$4N70XhCDA&u(F@rBw!KeLsWSbAr^Z2T}d-JXktY zvWF!1T;r$=mJX-n;7sLR?vaaJoh)^0ie9L+)ZzKMEYWH%T$Bm94~7U(#aqs`kG9aU zmo`>U$JE^>96WgR2~}wIqTAlwK=kwc2`TaVkSL9C%cHW(4w*-wU|%V0QFrz9ZC74B zcHiey3m3!Q9+c%jirY9Rftp<_ni=V<6@gx%sc~pC3h{feII`_^8b;h5P6%YI>$18v z8+^s#hT4@ke@3(fYhLe|AILAg{(_7IB7>7PL`{C02qRIw#&}g zTdI^u`OF-=c5a>CLg(_`%I|or`n?2EtoAY>P~YXzXy7iM?tB8ya5q?54Q)hv!&%IW zL})AWSQNN$($d7)HWLDJ`m92*e6>#0qITprqxSb|B(xRnw4r??7m-A|uuCxfbWvuq zwdU(;P(lIK?WN(NB6kVwViR3|zt&}A^3>B_=ES1@z{s9bqx7hnaO$)fY1n$87<=!1 zaa{w`DeB?QQFU!^l28}+qNLPs5Nzfb4EBXN{%$DS6ec^@FvWh=aiFk6V>o}rviO(cXGC<7sl{@?;~dy{%iFdKMV8(q<^*=8b}yJ+c)^4 zFWzdnY6ger=gqM19r6%$cU8=IY`H{MnqKy!IH!k1=};~exkJ3P=k9&}{jmp8ON*n1 zZ$#{g6e$iDEhe73%$q~pl2`Jsj0qpwr}@JU$hm7kTMXVOxdlrGXVXU?b5_2~CMLhM z=gu`x=2nsQ_T1)1;Fm9o)U*Xz~?sXWv}6&~YC2azUvQCrE3@UH?uXj|!5-XC;d=rW^ZMUTq1e9t+##OW<=kWkqL`Vr)jGa< zPLiQu#ytd?o$jgadVo}zU5P+IsY)al{esO)8l(2`8I#x?mlLQ^-3;#4pp7Cc&`lZLaMv%~4o_J@pk`TZspWe#BKe3U$BSE_x zeOM2!=4Ch9!&$oDG6d0DGT2!-CZB(!)V|x<1Xc%y9WLn|b+Ymb1Bre%uC&=Lj4uj7 zbQF0Wb|hdS4g2?aP5Rq1rT4adrqLhXPjS>wy=-+CCY^P&)d)Yu>7iCfb zH-{Uf`5e4Z!VCX^nxw8Tet%tQ?%6Tn!D~uS8~Nl>m(`mLr?b0q{zHNoWaLNe^WBVD z{Q}3P4^kw$F#aN{;~tV&&z@k!Dz@U!<4aS4Va@Lqq{xzFw#3te;wdTGGpg6+jPEss z_0~$jPXG!sv;cN3PJGf*RXA!)CD&EIF~eIh7VTP@4_?pyQ**4p>XH!qi2Cf6f+S&) zXba)kM^6uPmMvyF6N6}YgY~|AlKB{jAaTC-9It(l1x{YDhCQ$|(k%<>sR>|H^)lOw zDQi)L@hRUh>V^$?lyRp!x47)@yYE^Z81N3LIt!6{sH-$0(z&vn%pq`uJJ&=8C< zrdG@Z=-LZH4{xdPEhC36Ft|KfSQ~x@36TrOuSK~`e<5kkYzaVu{_>oLm8<{M7* zJH8BAj{x(n-qnqYvR5MrGa-#D%)M6xyeBKxdIe;WTT4B{trJzBS5~1)U;Ib;KS zw!FSrElDYcD17TrmXRSokUxg)I`!NLvTDtVetETOHz)D zwJi$J%al4{*MSy6{m7m>pYhmOELpUd6G8QEx3=}n8LD(-m4HY}n08#xl|3y>PT+qt z_>V+zUsF|cF|kIUcyLZrmkmqRBpMNxS-0+juT`#7h*(r~($|A(S%O+zdsIL*r0kE% zIpP4bshoe@H>3VrAk0TAElSIY^O=+?I;yL#1T6&pcHNSiP&#q9X;%;*-%iebe)jTg z+?~G!g2ghs>m+mnZS!N~R0Xl6^P4zsSMj4+eh$gQj<*R$w~l$;*sCZP>U?H?(}oe} zp8BqJ5BaYKy>eNF?5u6(MtK~!AYLuPmI}WTkc}%Qvl!UK$C$@Djim$wcftAk%x1zZ z<;d{Fp%y*0H3jNo&B2gKXRwnU9=ASr1nDJWtk%$XZf!TbP&_Jka^^^XIM;MJzHfD( zeMvZpa@O7{s$rdgadoDaDz*2AD*dV5x@Kx>QGz+%JxhKEi8O@xO=15Ch}5n8{zyjQ zHUNq=SNBcI3_Fq9$4q`2RhUVc|IG{JU2i@GTC&q=Jsp*K1%89;3-13+fAj3>>9-0# z10~i^qUDCopJUtLkGVZtx>GBC?h6Pnz zQI>v#pp52n5=-swa9UZ}oQy*cFk%5an^EJC6YvThgt`eMd{ndecUm>miToa^zW7kXX#o_*n z_yZ5$AI__g0xEsyn%&y9#-C#p!ERnruei4v+T(S|GKozhsf4mJrq3p;K%A>G;f_uh z3A-wm+1I#-#`Bd%86R6%_|*xM1Q;r}Zz4o>VuN!(gmynsAQyQ%q~&^PUTn}ejH*YK z_wqB_Z;i=l?v0+NUmi(vnki+R|4JhEZ(uS6BtHc?{9=Wi^#(yN>`+?Mkmm&74l~}l?@tZMkR-pqI_B^)D@$@coNdEd@?Gh6ulIH3 zHfa4Smmyp=+gTba;FXL$Sv~H@&#S8d2tx?Us=Pm~07M9b(d3XUphSPwaT! z=4H%;iE_qoqBXU2hg3zG{7+z?NxmNMA<6_Ce5NRiF#$Kb)Ur+Gr4Q&Gl>gd(1sici z`f)%ven#tO>4O?9HS=8!;Z2*U@xy2-wfFgKQ2V17J>}7wLx!qn0;;%OxU3&z`JB#6 zPO-{;+~;;)rSlnI$4E`}53(&bl~##2i}qHo@5>JpPBzy%AL{GE#Rb8A*l6{)Fnl=s zt-6nk-Z;nhPE;n!*y<$Cc+b8rShCn{)UjH8IJ1 zE_VfkX=%KMBDZr4R4CarX+_f5=5sHl;K)xTymY2({Vlr&93=yH+;5aVdLO#4Lf31^_O3QgM&P{Kbo56RWh{7-%w`HBq@>$CDV6Zd^%|F+MfK)VC+GbJ=_gWeaA6N&1s|;6fDg?OCG2xCpnAB+ZRJpdA)|PkjURD@ zL?W#U{j=M*k6w=|mqHofiwtNiVyR9GIqk(B8BzZ_^3~4j+^&^fy+K@9{=&QvWl9Ra zO_jt1`5%9x`}en!-XEv|*;r}fCEB8|CSuhmX`$-yn7s3l2w|ZkgK^4rt|^rdgppVN zi2QIzRFhR8h z9pcC%-pID(<&EmuG{Z|NdCuKa!6y1$`u8`7`R!<-A6{G|Pa7=y4D90de!6qmZv$j@CV6^UILsS}FGVF6JHT=T7OZT~Ub=cv3kEAAI z_&iDbGe2+P)t602C7s6^Z%BL0SbIh0&aGeFwY);;FW zK39wMVBg*Kq{qCIme!Z#jJm*=W_&75y z*YQg4Y17_?8LUxJjw`W8+WR2mm7HrmqrF)Q#7-N>2)<7s&^UmEBsQLp`GQQ6c2Mx< zklt~9>wx|`)w&^}@f!986$3zN!p=wE$7cgd6*Qch;J%vIirNnzxUbs_I$B^Cz3A{u z8fTjfHIp>4Gnl*{h&8%#Uq^c^ z*Z=SVE&1MW53%WDq9rh7H86X(8B4MJd~q9%x3pk!)dOLEYFw?zsQ|D$vQZ>x#v2k(f1x-asFWP8YD=X#Ur4%o?0PY zPaMc&`|&)>-$zO0Y~2niZEZNO{<-Qq&33aSTwg)v3n<{^P`>8nG{VHPyW(@h5tg{c}GRH zW&6575fuRu1SLvF5KwZC0+KUG7LlBD4iZE_Kys3tV}ayQfJBK!Dv+F0k#nZrF3vgK zr~CEozW4PVcZ~NQe=#U(?Y&ppbItk9-)v_Pe7KmJKuJroLZ+KiDf4;LS}z|4jk?vs zpYT_hsD(XS;&&$z2;kviL-j!a+rwAxqAk?AB*w? zaGs)nx0^tGC9=1D&zeKz+cDKE=cVO6?#h)_pnm42)N<-NEYUWO)o^$M6I==#UD@PI za6vTGUwpo@7AU{U_?SFhd0Rk&P3rJ-=W<1hlaK)BDY^Zjqw4g17I;N-?}Vq*&Q#ky zbjDf@SEUHjJmruqznx27i~WZBW!5gr9cI^q;jE^qSQY4M|LSM)d^3M}IWnAxi?gVZ zD$E3KZx4|lE7T#4T`!$q6ipL?`-`Rvh+a;7oM$>IWiw(U1Zx2{QC?h9$yJPTX z^4&i_r=^wN9^$Sm@G&)TcO1B{CA{U;P_xRx!(unyfCvc*is;dH%)VG{2#CPJLEZfv zwUR3f&*ZH+39N9~^4a*5hkaR!sE^WzxN{(8qB^EL_W9{}bFFdXMsNlPHP4EKNoHQj z#21SYc}6f9Pc}L4T%H``*1?6R7VKZ&>at`mlP;P%7HOeu@rOCp*}s{_U(0~zh)G)s zkg8@)RK5{N<4LK~@2cn|tGno0ZKR8)njhRCIwKymF$SuhQ8_##d*ClvLS;*rSy30YS)59#e+O-(`sBqT{stOPW#I&ADmuNRG6 z$8_pbZ9+MRKL>r$ydIg&Jc=kAd}Ws%)sqV1Th2OLZ;5mdPA1C=8qw8pqaMG$g6YbF zLk=^j2jY_?{m~6O+hY{WuLpO$`uYSa5I&V-OM!vTyIDNIrl9|gUBo@T9RJrycovt0 zv=t3z!JEi$W{%QVSFF$%-UkUyd@fr9A-H@H@K-2~Y_XO3m%QPE!hEXA4~)^w_;;nZ zKa$xMgYIB1v*cWE;FO(ary*IXm-Hflmjd6 z@c6qp=&_wNIR7zz_MI4#gAz`HEvaI+c8mToXhWpe^x*~b@k&F>^WaEqv*wa=sq%_u z0GYQd;AV)Nf=0|rweZroID-oTENgaTAj3g!dUUM;lfW2}NXg?AX4)dY5$qd$LzbmI zH*GOkXxKrmc#@dPY@#q8!Q&L}iYScPA<(75^0NFwNK%L|gpq+(rGd%&aLC_zy`Sju zAkTmwdd<)8%c)+&*xAypMgNKK1fp&U8hBi zfcEa3NIDuSXyA=U$o(7p50IAnYjTE7*DSOtBSV|cpRe%xy24m5tLVlaVgN8PFW^Ch z#e69gqUX#_h;1Y@@6j_?V+54O#hH%?|1%3Cd*G{$a*!mQfBvaTUYNDYfmpX$UP{`l zdXeAl7!@iyRIz(V0G}ye#mw_3#Y`MxX6aUK>@sxJTG}D8h>iPV`3`KD|%QQ4v1Ha zs;NavSF1!Us@0g~k3`xMaxf=G()uw=}J)7Ho3`?rWQtCn6_NfvJ zs<#B%R8vH@nMEWJ;M1zEVh!8(W?*1I^Wj+99A(4{0GK<;8|F>~$=vz0298WMjCMCk?LHAdIGsb-dNi)YZJi2JW)USAI38cM%qQih2#EXl)oi~D`|0`>-zH_RsgC@jJaa9l*0Yr(fcCPXgif%0_av1qs7e0u}3jA_(y5c27MUwQYYo8uSg zAA!>PfIhybmLy9=Ob<+Gg2OZ35j8WCL_0vpv zzQ7Y$b{D(>0T886E3Jx(+AgYKnkDclnkUXZU}F9C%QroW%;+(>09aWx&}`c0t>lc` zr*}D=KtdS*ZHYeryrINTFzsXWoa|q+WXJm=_O+n7Fv#3mf8V4Np%fY+Fdl4#kMDuf zUIJ&k_Uk5jtZ9UJOCDt#W zFg*Lxg0aZv(BefOoBCal@h@o%nQUsbGaA*`5)iuQ^9_^8g&+c~G*AFQf-qbs*O~a2 zjVRC*<#u1k={%Q)bWsb*zq96R?$+ z456=L0#!07aI({R?qfi_{uY^6g{OF^3f?3*?JseF}(B$(23@~1D zB>12Lqj9I>^oQXY)FaxvcbgWK9TzPBMD5#wx7OA61*FlY)!OPVSLX z_VNxFkxbLbUB!aZk~qvQeEM#T+ut(jP#8Y}b5Vh6d(KB%seW#o{5Z9|_-T^{EO|m_ z`1!Mw&+T7zB8eVNl{KICzcTHG~j||a!Fdx5CjM1-9l9F(&HSUbzrfpc@Wvu(P%L_G>(;SfGf=nGLu;#OY zO$Zd+HiJ=uCgq8XdhOg+u^9b^7=J|P&HKVL@0OAGtbX#;UKSJ`^D%l;>bhsiqRa;}&mOf)j35wV1p3F##R>kH~$GDmyY`Z_oLu zMDT-2G_MSVT%hUd7+eyHsG1<;G*$toG3^ThD&2T*SfGO)AGBnByu?l-A;-Fbhv(&f zj-LjQ#eT@>9>DYb9J+NWmZK1Oim1E($ruaooNiTC$+4c{y&jG8+BY1*S#)%}NU%;& zHf4gaedt`l#Ws{+sw?Yv0UjC1fCOY^_x{ZAgYV=Q6|vzjJDbjmvauJ|_9|==JkGqw zJyzUMS}H=4BQTxayKi2lGtKn!)r;nyJ<#q%1a8xsC?!DBHxL%2<-O$L6KBH{yXT`U zmH!4bCr4 zzB=qq{&c4ekFkuAGv}JkaSQ0oT^xFpn)i}=XFokLj@#wqSyq`W&nK!0rYh{A7}hW9 za)$FvhuOs-7ZO1^S=`nYuJxl0WnFxi0rEzAxZpjT2X^;gI*XluU1#lZ<9kE7QYvP; z@JT+&kbltB#vKJn=bMRuxXZVNfBX9Axs0m>mX*d!187h-B5_!L6=srX0Eon#14gTl z8xGP%^~!TtSb7$aN107{K~{<_I|S7(;~X}5bdJQON~X)ZY+C3t78XPc!?%|8iqDVF z>(I|*rylr;k(@S6p5rxiS4xKizs%aSk69mi{Fq@x;D0vVUtoePLZuU z-s4dpwmGyJdNh%Lu#Unmhu`q?9oepeNvZroKmG58bUGp zId*pGD7R5R+2Vq9RDUeWNi{%*c4@n=t;A7$xd7x=!6N&qb$^PcBk+EBY)J~mJz#$U z9D|~ngJM8JSfg%rA)It~VCJOZbSSbu(xIhhc~J3I2P9^D&Frl+R8>@5fa^N4P$Im} z)uE}dQ{Hoqf9 zG-3SYOhzoY(L}S+*q-?*OU`5CVj7?W9bsfM{F6=dJ^6O2J>K*@oTa8?ErWikzv7K& z>gTFHPJ?SamlIM#jy+%6@B4TyN??<+xWOgXVSacd+MM?LU-4X1IDdCWxO+zw-PPLvC8gsXQBbEXtM?cDdgh}%H_;<23z7X)I*mp%OI>V1gM z(S=qCwT&~9+Mdw;?EM!<^`_f7Y^(Nb?fIno5(+J@ z$lnOx&`-9yNEy{vk8;uqkhAb~Tk;`qwQe+w<|}Z=Pu<#X0J&8;?3eBZfX5d0dYF;fveeRi7SH^62thl6_pNSGUf#<;jF~W==!(;_QCv_IBjnJivmHIJs$q z2g6+~u9K&!2TiA1vcY|1lZ89Kf^M~^I2U3&1uU-m2i$oDT;1oNm>xvd?rgJnse8upA05(6w-KmW&g@;A zAD}0UGHI6{U&{Qjv+L=W{nY4O^lQI;wJ_&I;QMtpCOR}#-IapGu!&C^zufj<0&>e(RrZju%(QEGht%OD7Q{T-J1z-LsUKzW=mN9?J@;lX za2=BH`?ZggySAvl%odGPbo6_Bu763_XIdFJGmCH=Y1;pY(PHN^OteE9LhK@&N~d0Y zl4z^b=gzTN>=qv0Uh1w~_vR@l)%N8EqT~~#$a|!V-;|bd-}IzpC%hZB6bC+8(R>}D zFOm4%t2wQoy<@2DsB^lMsme1PUb-P!UnJ6Dt*5)Mzil|I=K0;Vv*1irwva5j>D)ME z19h!yD8b9^cX%xO^z=w88=%S<3z1_zgWp#fO#=uz6QQ6_4OEaF{)EeOf)m-bJrFCt zv{tq2&y8tkfWT*qAFf-c1T|)pxrlf2=Tw{3nvX#dKv=)t{HwspoO~@TcP8>p{7Zx5 zUrTh!m$~Sx^6Si4R#Tost}Qq*Be{XPlkCGAlFZtb0$bRvj9g~y#V-pqVuNc<|;U%1u%^Wo8KGNM34l7L<#+@onZ3F3OsK=1Tg5K+glei=nk_zGIE zZYQ}@;-;gbg!s6ZNU^xSff3hO-fd#XICn?x~5o<%}Dm9DZ0Yh+6=- zF6lwnwPK)jE;Y+35W~yv*@@ zAk~XXlhSla^pY-Fm=3cZ(sZsPZ~;ST!^es@m47cB0ITM>hTQ-Srynze|X+# zK>7{wOBPU-OyA*Oc{H5~?)RMFDY^RL$V@h(W8XQ>5XepoKNU_fDK3vY@OCUS@;{(- z4Bd?NS`FiLwOTK#|pE5Fp!zt`JG)_cj9;Kgz!dnYOnv= zw$rK9mA(A>3K9pYvyGeIXVUX_eeHZ!;T2KYVQdS~h|teedz=0IO>e8X?hsGTemC7) z@m$a6Wff80USAm0k|lcUJ%zbpG09vo-E_cXVasVspi0+&*fUvf(711-p|v4W8WcTq03Zz<^n$1Wp{ZjfnzQa_8{`vlm)0 zPP~d8PP2Dy9TibI=Z(>dc7xN=XN4Xl;PXbS&iaMmte+)3k}foCrc+tc0v_kZhR|SZ z#<<@2=%vfUmZ%WtNC6fVBEff~=HB*KeHi$QgOnN2Me9x75BznOSKkw)@tNPOBRWbC zXqLHt&Y1p!T(^?3@8PcWr@neIG_As3SHPLu{oLOm1HZiNr`xghO!3puMaFdAuy1TZ zGpYyH{cl)559sfA$);>ba_QzTcrDjxFcL!51O|L5&}X<^o5!T}G|Eo^&evSKdZxrr z9Mi*beFEIHwdW{4k|v?FK~+!^SoxrVMWf+rxt?}d^g?zUCytLojIaLK5BI4O+ZdJMb^lDFEiTK@*sH6cdM^>2YdZn=`m(5_0cK7jLZ>9Ux0O&)K< zEzizL{V)UJ2gQ|p9u*Sf1iJsrc}5OP23Uz`kLPnM9enWAVsi{DPX*E-32 zm4gG(HRyla3Z8Q8Ym)l=D5rv1sX$GYi*N+>5xwd_NYmjm3Bb!@vU zh9{SXT3oVn_~TTvG?`AB#IXB9D|TNzzvslQRsOF#4gf(rkmOW6ynFBFU~4~h{cuGD z!0y|c)Ak&-+Of9hF#pJP^J+OG}l(Q4wJ~dBkMI6$xM*cAq!u<)D0Sl?g|ed zXSMGuav#IeJ-x=@tRsvlfmSs2zv8bvof+{2dbJhCK!EIAg-|`EV%YqHVK%Ob_zb zB+5FO#kTo}V=iK8D{=71bq;L5kI$6ic+jk|51#mNw!hBSd89NJwd+3#Q`+f9*VJBW zmW)ET(7fzU=|o2dYVY(M0EZj-|Z6xy`c-X zsYK$pOCI;ql6xEBHvB~gma}t%15C7*HaY>3G=d(>vFp-Sk`WC0ZF3j24|FdoA6nf{ zqm_0xdYVW;??~C^%}yE{rZX> za>DNSEZ<&AZQ)W9dAatMTJ2kgu!loV7Jj*e%$r02g3J9I5gJ$}jjKQ6*t|V1+KFBf zIJx}c3*hDK0;DGQ)|l@^F_p=y6P&OUJRS1pHXT}9sHcK8S}(V$4_mwS@1GJ$TYVYh zYE*Xm3m6kPdLV_PT=FApurn}42ZTNLWqq<~io^3H23+i36uvTbNhJ3+>v*qHAdV+$ zH*y#@w8TKxH~%w8?=T(5%gaj_Q&mCb2?;4BAsop3R)# zb-nFJrmK=iIAOhy8wSul>XpVgVG=t2liBvX?$J4;GS2}Y!<5MfMUkFGEUXcEfmZ7R z%IkvaYe%aAQKlRnbHO*mnBKN;qe$A#4wIFqS8&qzx^6A%T=oG>4f7(Bsj*vl!p&Ds#TvOhi}ykx?r~ct?$N${ubzStNYz63s>jD^o?I<_h-8&^E(pRm#$N>fiftvkvYpD{&lvuBrmhxkCcsZ)mm|+y6=Qy2X-D{5`BPS28y|hjEGJIoSqxic6nbc{RKs2Cz9t$gNUSu&! zc@YhC`t1HQ5Xi*xH1X|?Tj2jQ#O8lg8Z1Q$WHr7ynz0~Km|X$zDHQ_OUe|=r=~ReH zl=9I$-ZEGtLrw0VZx9tASrSdS8O0g!uN&xxF>$fI#a-GVd#0Hh9Bk+A>T1;}Oh!tX ze+$h+Y2&xA`Tp^>fEzfc5oGw{XP5~=zM6FL@k7}^=p2bCr+V4u6S|CaH{T9zW+193 z2>8}P1P}iuhy!@ggaCJ4*Ly}~G`bad6uppPS?v8&QbTqm=%bTPmwp6f^@D6s_OP&O zR1`JR(qMG^(L1Leh6Y71aL@SA)Kke7^3laY%^RjrVf1 z^*BfZdn6XZLN*P!(K5zj!i2Ka1S2JI33+UGc{c>Qds+C{oL7RIu5`935YG^a4~Yb8 z5JD(5hEtHWJ=M<2!Fh^RfA%^EU4^OWckIW9?)&+HcRmr%Hk`*Wvru!V4Cr_vQag0B z%15oLSFXUllS7MPS;f`_@?I;SmyNiK4#%gG-azOn9`X?Q=_W2&>wqZ6n-mVP|`=ZSOkgMAMeOOTC3`(QsmPbBW5}l}?z`FqMpwQO-Ej5LkHJ_#ARTMx2~DqB zdT8UDxbm&pdgp8Ri+egp&#V9v{G=(V{FschCl{yk;@+4GYbc;Bzbt>nuSxcl zh=h{NtE=+^stKN?fa_Z9`Gs+c#`OxTx9jdiIsxtL*CSTdFqKHn@A%!uKLCnlE|A!q zvdQ1CV@igCkB*nA#7FBO-0Q^L>Nx!hy$C`h$k*UU3kyPmcSSf87|E5*IbKxl+?p&v z^bvS-viagoL*iTv&Zb1iuJ`6qICbKx_QxO3t`~E6lq=?Aty^myGKhN|}oCV|fj#tg=T_SCFxif3wq0X3KSor&=*oBu2Vlg=49Oq4?1YXu+cYdsdvBz!`d|%FoV|nYYQH+;1xafpY8sbZv|4WgoeO#Tr)g4?1sCqR;R=f9O70fCsF9l!k-=*aKy z#OG{OQ!4Kt-PflVcwB%%u*?6yz3qT-sx$!YlHpsv=&hV6Dn9AarQ%XV zFT^v*?^FTk!qrkMT~^2jMd2KiZkot*mrBL?L6 zkN-IUf~SHE^56TX^vCa@z}&WQ>N7o&dy#C}v60_A{5ZH&l+X)mZ1EHX+-s)+B$^wwTs{~Lg61BiVVZg{nXdU;m;HL!9Z{UZ(?R)^n0paz}LiPI506o<|fgj;^ z48Iczk71op0o6?eE@%RuIz<`@y#zbl0@ zA2^b)dNoK{5trt#(AW)j&9xz$cvH?Nc$i%USsFtw} z9|7ekWOEdAfDT;+pprF1f4@S1|4zR;B??FrU^;)Bt~W8&iabD71f?0hvX#w6s+nrP-7;n7`w~Bytlza zeLSlF9_qVUk+oJ&gC6YX%0MuA4jnF3GxkiFH_6Zh2IRMzg1Pie%uM+sS>jWinL2ON zT@Mbl9Ie^s?@6xQ!23rNf${KU_uQ z47l3xViBN#x|>Ncfaw2Y!}fw`!H!vuXU{hJim6U0X#y3h?Z&=k92r0X_nKHdVQJw*23^7nD9BECw`?52WWYWQkiE9;Tna76O8>Q=67bc@KP zsw7aSs*)gX^|6{+V3#0^uNRo+a_R&8AO;ZS}!F(R1^#UkIEThCTd?u2AIk-t2@Zmy;XZ=a&n@ z^P+gge561RW7PWLRj&Vi5R2*usCl5;RhD$4mECs`NJs;@8iN&G&CL4+t9ZQbujPC1 z=6|(n9hp>L;Y-a4{<4ql(7-MV^D6DQ_O)&QU?3DPwd_HgTenv788_r0R<0knh1tkM z=EBBS2v{FN>z7i&7--RyxzBj{*knLLxX*u=&w&AI$A2*tOztlSNLU`5JZD+jWMHN5 zW?|H5zm5nZEUqUAP>W|}bX8Nm=r_yoN_$Goy%!?D)&hk>3)3|l|MGe#9z_A+y?9n+ zUrg}q{I7~SZy11k7%PR%P5;*QJhGYYY))b9gVv+>||*myq;jo95Ng`JgfpUr*XnY{PCa^5i+Ha;#%RkhWnaaT~4yRu9OJGgWwVt~J>@+?($eb&MJAWb# zxUDurSyQ~-CO&hS=L|ZOU`Nf{d`i#T_7TB*`k>ZP2`yx*=||V_eIO8!y1e(cCQi6(3itjac~B%bV+%TTRoxqkt(1=!&}tDzB#>Lr%_=a zW&gl^*Cg4N<6R{%hS|u?n9ff;GbFO8$pd1g4kLM(4%umR=(hWHP+3q4Ebx?xr^ae!iTt@8Vlgo5OWhT- zUh;O++8la#_cXYE-p!ToHzf}kW!a_<&vtO3@irUm@@TNwmE9N$r3ay9`1d(`5K}fy83gm0Sda%7RTe$34XHrQUr~ei);?h}KJ^j+< z=hq;pXVQida@{-K44#PK>0Z-Jve{eDojy((FSGUTVq>{-SwRTj#X9Eukx12~ri+Ee zG3yu(#&4At*03N5>~L}Pa3pYMa<6L=qw`HtT(774w$-`mxU#(UkgSJ3Y=vZ}iNpJ= z=~fGx`Et45aEf6$fyK*{RK)q}?A67As`A2cBd41iL13Y6O6G=bXA8CSVQg00Jy!K1 zH{1SruFx_2wdlRcxHyk^_wfqVREwE1vulN6xWp>>dVH(4q1|Gt!D**iLlqYyZlSq< zSdY%A3+~WdtRQUCC>Q&Zj5r^9DoQELG zLjA>a{sM8iQ9>mvdeZ@1F~Alp%myGg{cri5I)cDPGi=7&y=_a`$@{@b;f0dG5(p)U z;FAKc!Ht5b+o4(5G2lzBzln^y}^WJR1Fo2$|kqn5QUq%A#Q9#D>`QE=~4<)2jx1YXyx85BZwM*N_p_H zT6m{}5g(9|Z%nE<_*17yj3&xMm$86!kB$d1{x~$mc63>Cjog)wqtjwX>k68jgJUn~ zDdE6k>1DOu!yCRrIK&Cz z*~rPq;CP~)(icb*M*Jv_HrzPLfbX}^-3ZxCU#UM07TF`?sfPFK5>M^u-krN%Ef^!* z&xId^HriuDOvfe|1(TM@8+Fb`V`ZQn8sj%2mb<{dw8!t`nUY=4h$Sbv)<|wqxR({B_|&P@lIC!OY`}P8J>A~wAz^Qgx+d= zil)cT`X1G6t;==R>$9vHL`7}+!3r}F(CW~zTa}}v4VBcr@Ps}4n3*xaRYK!ZY&5mV>j#bAEpwH6?i^4@8u+xC z126sR!Oj-#d)qRT%2A;v34GbE_`0RWAJW84;=0`4UBCJjTQcktu2bO>xP%>18LuX1c-Px+M--b_!XJeGwT&yYdO9h?Es5H`NJE;a+YLf2zc*@ zE6);|ND%fR`rX7K0lH77P}QP>!%?+}AD1%YkO5`+@Cu=}6$U*Fg||Mg?^&DZ{) zu3UnAYa)OCPcc~JnQOUpyjZL~Kuy|I%rL;j6Oc`2S5nC1(9TxEI4w|{k>VEyy!doD zz!6aB)xV6R$ZuF@r=(He0!9C}jXcwP%!&U2NM2AW%vN)sp@YU}#Zt6nEGcW(Pt(j9 z;0^w{`KK}~sFrVd(;rT}(^0hj=o7A}#&C3-@hK?n8S?01So*d{1tqwas^q?cMo~7Z zfVf8S2rl5T^YKN_BOewZFM)0?#oJYlfj;`&SCTFMyJkKu;2H&?0l~VPe)^a8a)ns? zc&A-&|Mfc#d@VGabUZTfFpExwB&{dVQ_jevgyq-YBO|b_>pzl=&HIeaL($MyLu=FK zu8vagHZw}}Oxa`^^*F$2Hjc-Me{;_wv;8Zc$NDa6RM`}BIZI9{eM@G3zaRq8m&}a_ zGJ>(S^4CO34@UG zhi+eQCn}=a+~gIjQK(i!2ra)`Veo;WG!n?r%PXMUe5zX9{yv=1l6chzR~P7tc4&Fw z7V7OHBe*`MzGAt~@7P?0Rv0EdKR@A18JJhba@_bzNkk2r~{fLK+*3sm)!)uCU>sjFkbCyyI7r`me>TcA-4!H9g?kn z{wWTj`~!$XEX6w@Q*ys~Jk`FA0!@XG?$vx#`-N2$IjOs{woy>`_``_l$Arp@&6(OD zTr8xwIuyu(2SP>^g5_Kp_718n>7iRNVFn8{ySS|5FeW9J+->rlZ@$CWC2wxL{vK6% z|B>rnOhTL~>BQUi)vN8>TJQhx1?=C`KjNMjTxBe3>>{&~uyPO5dB0pm@Q4(Lrl@K- zSrlF+MBvH6h`bd>Kp`GffrAq?zmf^l>_-rC;t<>JK`Eu$Dt;K zVQ%y?bM`7R$XuaN37s1Jxi;!}&mccZ9G$oBpj{jv3Ilrp_nh8&Z5D1hALb}AT>ik2P z|3RssK~<~u8OV|!F_#Z z=CM4#+a6#3vTY#;olex`O-+6q6<&*q2fYdb)0{yx?DtLcE^<$PszP5W@M7X^ym_UD z)|N78|BHrY>}AVT-tJpfAI;`Yx{Me%-`EEr1q!4OH0{ZMm?pXUZNRwlS`B^=m~TQs z$gNI_LXSKgnGwk#4rFr8`Th?jK?07^#6SGsiu-q%tXBZDG`77F9dL7^qN)HQ{h$Hy zE~1)-1mMV|i4Sx_n%LRdnIwHoevJa)0bRn>&Q$6lst0@mXvi0&W}h=)ZMxCy^*{{mIFVR=_^h2*gcXi4{eso<|9k`5eEI=ArvF3kF1bE zPY*0?wAz%Ps$y(FmamZVO$z2s!LPeqc;b)K=)rUZKv}Wwo*q>cv%$N_;$#6gr5QW` z#)xuG;Yp@cKM1ID=6~Bvn$ZyW7mE!7vk?&DJp_J;817Dp&y%&H?#I(KMc4S*`qKIj33Z2QP z;bkeB%d{JzfG`c2UYU=4{HT;>!Um{Ti`%JCNs0n>7^K+d^&e@*|55I(CpcKXODmba z`NwQ((S0F);E}rxA1`OevYchS9^S5cE(L1%t(Ka(qDTn&oI+`LclW~3d#zfSu2q~j zK4>l$i2uC%)EEM?_ z5F3#si3vmXeY*Rz85u9D?2^Rq<01d$iq_M?;A_h#Ccz{l7#JNdMPK@!GpicF; zM+XS-A^-3H+|>VX8=b#vs4uG`ua{q|KeT4WC0T}zu(i&lTuk|8>Ij^hSlD5o=MP4v{E4Qt}cXjDvI zXBAr}D#Y?}M=3XkK2pF^mQ;LQZ}O3&Q!O^i@E99YWo$UrBcX+!t-$AiCw6%?F5fo` z1oVAkAU{ILt$o0Kr{)@ta@E2jfasXur{-8YEUE6888_+|R87CnQ=Y39s51gK7Eyrl z5373Lzt4ksFf};nW<+bnxvkkm&Mc5EYC|-eJ66vkn(2O0Psg+-gQy&ZkIyVQmISS> zuMHckk!ZfX&i^(r=Y-lV{PP}LZ3bjR-8)9{1AQ+e=9l16ac#DR*jLjc)7Z*xYlZTx zcc%HP%)dqnrqrh>FZu%E_`pWZDBJuHL7Nv~onOn+X;NK0F?Lk7M-a#}I^=4uN}N~LubjCR zDBxkQcWL<1-3%Z@KOQlhR5Wc>johu|x1S75oh+%@f*%u8qX>NTxn*LP4(XKJV(cVN zEs~S3HGf}iA4e9vZzpvt)1X3C;>B^srU(wsQ%%Ht2;`aN!5!G=$vYASqV=*3OY1S%MEQ6(q5e|DS*b*^aIfFDx`ZVe-%G3^eS zrC3i$UdEv5uq||uyXv8Z+I_dTwx0saoVCb1T320qrjBYBj*YGLy`t#!_N_~9g4bm` zT^X5c%5|&aJas?LSF#SJo(6fKp`Z^uvIw{kxY{lJ)@-AdB0P<#nye$yI_M(B$zfo` zXfnGzg0vQd8(7#`6OAI6FN2A|a(wQc5`!NJ$Lve6Cp8>}GYiwjJ6{=>KXC8Ic4U5t z0z@AkKEd}M6nj1xv3E~@74>r(>yGEk<(Pn~aHmaI)wC-ET4W7PAG0 z=mmhjc|a~tf46E*3+q)#ntyZ%38ETfcHYY{Mb)`@qEgPSkecpzNChUri|x2o0ezUv z!{^IN+H71}dqI)(d?Smo!r*o3Vw}=q*HBtbLF)2$(=uMngr!I})==dR;nv#QNbd%! zJkczJPNm2=efke4_^)d+F9yr8N@FPxUPsR; z0c#;+$~g31+;gsE1DPr%gB1S5Lumg>?PyC1$?he4+Rl^ol2?V|jt=0ECjvo~LddIYzN7|7tH<=m8J@ z7B9cA2Q9&8kF9GpV4(*|6>p7HYUT1^C#TH7;4P>JtYHaCnUt^l=N~956fDkV(tp2u z*OV+-VRU1(j6mY^<*kdb^@6ZMhqAgMQSR)kOjyR+ug2D@wrHy`QPlcr+V^2@YAYI! zV+GwB)~ditP<4&W(!P1M^{o^8yYE~!>cBOuwcX|J|f<4wqMh7kq_Dt{3aR2s>EzduSr|Si{A9o#zDkn686W zAgR;f8u>?efy>v|!x9Jv-m533{+WviwPLq}m>969U78F*9)7`Rxv~PX(WYZ7O)NbR z&Gu_se@V@{AN+v%+->8UsY#k2@d@2g!DBUg35#8M zZ>~fga}u###}9XsXj>jTDsT%bt<&*Ps<%-+cRnMjKb9JWkzVB=A~vxa;3CDq zuvIR3L&twOUzo%YY!^0;3vNQ(x3xIvmLGcXoRWw8xR)T5G;88uel$%>Vr1H#7m}h} zn5ntCi3%9uCJkv;xQKybr_O=s$?LCnD{;+oqO@`(OD?#vOc|qN8Tk}$;fZ#N`Z+&J zLIf|)86`UFnIkU<>OR9y?73&CZBHmOwhEmMQ#R7KHH&Ne%@3bymUrsY|KSTThs7+f z-loBZ(D}M+prnd4&nJzfcTu9pH=a>IT5Ar-re9vy*vbm?R^F9u+$;#mEJTbl8J-%} z>z&Un?RIv!R)hmJ1p0oJ6kf}C;Sf(!)9sX&QPt{{)=Q3UY&HZ%e#6bynFh*OQAC03 zxUQy+WBqd;kg+nUGA9P`WN3Xq-G!*j`=fJjuYAVBQrx;;fF*wv&JL(5$!saqEiweV z?Jl`p(3+20VAov*6M~_6b#!I=SLO*}alyRnT>HIFn>B!paVEB)`CjVV)WP5Bk&$-c&Wdf)kPq~447JLs*gPR+^#D9nTOZU zh{_G`H%em-=qz0H)YitgVpwp|sG>>qqlKkantBIyl5*Sl4$hL&&1E^bynIU;NZ z>_H{Y73YoW(>!gqxgGhQ)>jPGDXzyN3V}V=t_us_uCzEI^$XRnyotvc-m(P^%q->} z3-Ct#N_U5bm^p&Q)BV3(0imyLDl zr*b#DH->QW7MscDh{^F!0^gF!O593;6V-YU;49ft+Hlix*4PaL)T;XVR}`*lB*050 zIt#x2n>bS8-u3(!L$iV}Yc`XZvEp1<7HXJYQD1lqxSm_$Yg`;p);nmFtO=*j!QIXO!3#g-rY4)K=<>XY}sP?+`>C;M(BIrzbnjn(=6 zoz?|E2zeB9-wTu8dOiv{uygy`N43CIPaf9#5NZ4=Jo{aktCx4C*dQ4BClFh%Xw#PT z=gntn#D6MA2PFIhKXjx!b2q#P z4_fC#c+Ozr5&5mD+tKW1L=&^nC0A%q4R8gdwIK@QG3QI7!84DEyET%c&*XTYAq z*Iq&arB#~t8XkPRjM_5bxi=E=aQId3+=syU*HCnQ!xWfLrSsaUA_{hCh6nC!1}Yl_ zzVWCl=sEcho*_)Yzj4nsw+M5zE3S`~1zV#CAN+|uE}ro6dr%dl!hIG30$29C3j1*6SmPE`5`_@ON!2p7TuML zN*Pr1p>zILB#$c0!p~zw++{uH#w&aD+^$DMC<&8`o1Fo<(T~9o1T8P%Jv!pjQHM_C z8v9Z@7mAazG>!D58~$IT&#L0+Ac=6r!W>=%#j3v}W^ zc0?A8_IOi)0sUUs)&L|);H?W()4}F{RrlRNQ9WIoii!aN5y=7~Niq^8tK^L2Fo1}X zVaOTApAsZX&N*js$Y~@CNX~I2%n&A!XMh1_N8k6W+S=NxZ}*S=>f7_ztvh{h_w?=V zbNcjio`XXJ@kf_k^2$O-D5Cub_OzQ*v$@oAz5_fEHF=v(?0QVLC_12V6J^@w+hxW8 zazpAIQ>IwRx_&KL>u4ZS9Pn;$a78a#8$j$#UB!*}H&XVd%_T8$I>Y}cotFr73VKen z=bm{DV=g;dF?+@t)`IiS?wZ&qyJC#$>PzNT; zK`8Y-#wccYlkdAts6D6iy>9_~j~@lC*%17C{U-OmYne8k)DX0&EfX{(RcT(Ewxq;ikbgtK_TZf;$B4DC8I1Yf;f+2brRz;&dxP@ zmVKUI0W?~v3?*%LJGV~ti3Q6Xza}Vo$DDvI*VD3qTwmn6>rpw8hbkHqmr}cIWJiiR z89z=J@4K6*lS$qV*^h}M&t_Ua_%*!DP}qcbJd@f9<+qum$OBN+17R7TWtKi<-1^N$ zME0d1nlBTSj0diqoa8LmWiPcsp$<=P$7V~vb@p=4k1y&!nHB7p;ekp@z8%t($y^7> zir8;vTN~Ay$MfTPMGvA|DOc;P$8&mGKEx^%BAYxD`L&F`5?J16zFOoV7P$a?YT8&Q zVc4U2&ozoyw%YB^B^N|yIx+l>7ne(To^G7?jD(CtEt9;b!bmBT`jLm4V{Esxa^m=o zL!1yKPoI&=Jm8bomO@UaszE?~gY6iKXf}m|Zs_A-_7jz3hc{&hfTRqKAB%4Sfxntx3sli9rHp}4a>Ohq&J;$g;y$x8jEseR zvi0x<92wJK0xp{NFjbtXg}ZZy%vSc|VyYO}emX&eusDMh$5Z&Xs9SU%T*=9~ufMm^ zdHee`-dZuL9GrHZp=%_&mbg3S9_>ZI_8Jj4pYZT!O}nw_vZ$Jd>J;614wc3r#O%PHH~irobRtnfV_ zQ2l$qwo){=A8bMt2RnYXv=*svuz0ah*~p+chJcTCpbznn=f6VTPOqJy7qoey{Qj50 zllw05T?W^~AH};b?bRNzt-NS<8K^z{33DULAq2DU{KN)$R0e-XFMUUBHe(`AEZ&UU zY3(tw#Y~^Bw!c@iJexl5>yKd8pwQe}zG#>a;@I8ZjNKKOJW@~e@|GCC199@bxZ=A_ zTFf>0bW7!x&Hzur^lP$N=i;#H%2e9>w{F3OoNJ+S$GGV`@em*iv3FrN8HynKT!6!) z;Vjv`b$pdj{2s7nJ$oyz*HUZ^&T>%tIeOAM7jk9tYGD7_r2gH77UZ=&aI3o*740cB*chP9lLtDI=7f69suUJvkQ)6R*nL`FQ=OH* zb~rLu3uys*da^TTLrNkJ!tPSms$ua5-|sm3AJx!l*XJ~BeYu|?5I2JJ#;RtTAfY)v zC@QYImNp;yy;YL5(5u1C3NM)MF5q*6=!`twj*$gz>hO)Fxl7lc-lZ)l6<*mO#P_1)4i8 z{|w5vdW$&YHDopSXpb|ysO6U3I`ADJ zrd0#m;WPNjoOtm`tA|eMS|aD8_G880A-9!$B=I*_EHaMFcD{vQNt@WJSz;F#@g-ZF zSW#z7Yz*zyxTP-ZbV-kyVXb)+=hf&{v6hsJY1^RZ!e@}%O3!=A zzLS3-8r$5lL147h=Rs=FIa5TQsmD8ECqH~tmW9vNS-#dQf)@#E2?Cj)er_tt>Y(eL zx^+K+h7D`H@bxx6(6WBfMh%Z_XmG^J?c?-~R=kKgqY`(Wob_cMB>_*Xf9QV$4Pz!P zDE-}V#Mkm9>=rHE5FZe;?fcu2kHO_H5bssAkMLlhy@NN&x3316M*wLow(s3J@7`5a zdHsZiV)D+jN6(&o>d#eaNHT1ksi;PpUM5$!oxsqoGp#d5)zpCc6h6}mMGbXA24wmN zukr!iiu`w5uBrYE>edkI0Zd+vYCKFE{IyBu9vrv|(j1lEk{CP65>A(fW<{}XgAD86 z`1#nh);iwNhVVIWG8j}lkIb;A6y37a#sIP@f|eqgp&XEJA^?6a_Ca>_3RA{+rzHn# z{epfrS3eJA75A({<(+Q+k+Y7V${y;Z{fcO_uuOR}R*D@N;KyHo!y{QGv(q>D zy!E@@p+G@GHLh|ChHY2t(`~7KJAz^#m8dqR09Yfm zhU-OP3@zVXWj(#>K0#O}xj@d!u=7)T@~X>&AyF!88tKE&%BfmW9YnU?Ds?+njXkyu z)(j2?TrKel&t+aYNiDsle}j(9UJOzS8BNPcT+eZEGw{<2C!!bgY6Y?sKq_RgU+A@&sypZg6-g6!tpWp?dpi4T0w6e#lIXgt{P6RvUD zU`*5}QmXz4JsW}CR3_1{wy6YwHZY|Sf?voiE#_}_+7!2uV8L|?)>;?V`G+}tGR3%0KlNDZHG)~9e;}4fS#jN zVg%vU#uBftz#}X*r$3JeM1}nG3y)l;n9WWOhs}1E2flLHT#Xt18dZO`XzW(zVzKJX z)t96GW)l8Bmb4+5gW1;WpVR=4X88wRTbIf+R%>nhOi+ej-+UvMyA#t9 z4pxKuIx4UAhmEJ@!b9V9hpm5CEgWKUIRmlrr74ZFm$PWe+_vxpm$$SJTmf6im@U!4 zbc%y1moej@-H?l!0H@(q_|is4$q}Z4Z;X(>`ginrQ7{n269;`dM(SAH!Ay;*Dg*WT zSLt6}Q9>_jWvJs%66G$C@gjswA~$?$`2*qM$#8ryD!z(o;d7GEhpx$^&^=_8mt3DU z@>4joSxN!QQU){RlM}q1enWMv#=K+N`ycj9Z-d)Ql}`XlOCC8NqV#{)yKlKfUY(8jCJV6G$h zNaE&PA9L<*5NoF>V3ew0a%xJd^pFw^Ox%>QSceGe2bN=2PK!%QH)~H*{YVKZHt|k9 z70x@uOe5NK0Di$DvI_kU{=J24iUO8>yRsgRRquh`q^w>Z-_M5Eet3;dx;FB01Fxdn zMKp(F`D`=$Uijtrr{#&Fq{hF>iCO3~F1y#yJQw}ByQV5J*{>~Io8tj&@3(-!7PjQv zpw;pK(g}(;z>D>nz1l!tHa3Nv+~GuTH@@|Yfnqr*quoTC6;CQ-o4)r4T4DOZ#+ho zU%VdNo4aCh>hsBIZ9Cb)LfW%FlqrQ;JmZq?fY;z9(3(yIra%&-VQb=aR6f$=+q;v2jE3d(h`TSW8!$ zbh**pQd}@)P>yohY+t{cl!NmmqePv9t@_}=k^?8dMl@-?APdV#bLHl6e?*SDzJod@ zw_)ddsMH-E-YS*mjo7SZzXY)NN-C+C?NBk!*S|@DudjdC8@X52wcx%R*h-jY@#Ue6VS`0JO5V1Gwt!;m4g9i7wsc`8lw{FX9sX2Uy+Yon%E6YWl z6vbrwY#~ty?nGwelqtWG93@tuVJtU&r2_-1dwgv8HDPcpEjy*qG@|^_J4OxKK|l-B z5JMFSbqI5jdw`U6byRTDkfOR1_5+2klYi0a8_=UegcNGqexRfYu?WOa1k1zi{Vu>6dO#dkCy()ji zmY+z(tFPX2Cd@tCakg$NnXTqx@%q#I0}+wu=1tC*wU&zgnER?L%)O!<+Sbd({= z9Ce|*r_H%}?b>}x=fi}Cz2m?~+a9%=&#IkBVi>l(3SRxXNCw)h2FzML7N7Oqc^w%( z*ExMDB(gP_YJmt$PsSgeoH;kl@GYVTdL5=Z)U}K!%n#MR#4z!7KGps00*9QdbzU?% z+OMU+%f~L(l>HKLl6eMkwM)AG!;w{$FN7`+sA4Ukfa+2n)7#&qAB0zzY#M8Rq}NK$ zqW&=>|HDlNEH37_AtYscu$c7Hs9YokEwIPSz^zD}i^hV}m?b{=w%F~!Flm{5-eWwq zJUZd|IX~-64hrU{8cwtWZ9B$$Z9tcpVyLM%@S{}FH2M-nIo8o2=YDXk4CgdIi#AV9 zfE=D(49kVXWRIO{=9VHzjH;S1I%WZG3i?%8Jxi~;sg&BRS&tjCqD8#u zE0zyy@;qA$^bQOFT_-0aNtRot&q}F=4Q-aB$wNrn0xcpWgAOS1LI{m&)$M|J)&W_- z^`(biLr55p?d@QIBVNkxD9caC(ImL4vX6~zZ=j3VgsDvGm6H5WJLp3Rfs870s<)^Z zCqNpbne*hFM?~yuvF-i_PjSROFXW<6-cy$0c|tl;d-d%s*J5ZS(~q|lu7Ckv1Dv-D z7u00=q_9x+r)8@1s!{n*)mI)ylTTE;m`@hR-VrD+n%X;EHZZ#C0QhlwLJvQ2d8&WC z{h8dE;RHEUW@bi38MWq&_wY44=*)#+kbwlXEm;u>^7uCNG)}Y2LqIbK0kKj1;+QDO zEB5AovMEh++Wc6k)peJSauRio9W3LI8?)o;OP{=T_(By=Kz={a1h@qrWY<6HdwLti zAPzY@1}=%Nse0sGtbH;jBKz&H_`~f|NL;*+&tk!EN|SupVx)Yp-3%6#94V$h>A4!V z_^$4@TzNrm-^KiJlkCy&DMiuzsa*4;*+~)Sjo94Is~Wq9H)d>QD1>tZ!TX55r6#IQ zy>AfQW=q8)Bh6tpqd4)E5<)?7RLBiiFkdZSA7v#D*vNK(V?y(bm1i@oRBjC@_=6 zb-$e+J@_bnH|I*rdgvU}J^)Y8M6u(er&`Ssw3EnlBu&#a>lAQMoZavjWaB!*`ny>x zy;94hyC*>QhNwu8lIfOc6gAd>+x^7@*-K`lUzp`D1(+TK{EQb|Kc@?YXYzI(M(y!$xk8e8B5dFPy0P$W$E<$ADuW(po@Jteaz0=$}uVxD!U{+l!afOs8@ z0hiuP>Uk#`zVe7Ytk*pdk+EeF?4j2p%d-dfy@ZL=lS8nF=Q5%>gPn!vDxtya$I&6D zcfmA8-ujPu%B4N(T`vQ24MmNe8AUmw-E6#@ zcQpL`_sCYAuX<$_Akr#V6F zRIwov(6kKNC1n{f92#F(zT9aaa8Or>!=lbS5h?2_MIhf@$d$ce)@)GlfU-dKf-&50 zH&EFF%!8UW8=z;^wmlm8Dfj%YJc#N`372n;SnOJVGxfAcHbd%<8+^51%EM$f z#aaZI({EGJaRJB9<$$iYWPLA{HxT5jJmYdwAZ#aX#*}SbpJFtH4jDfIn*2xp)d~L98Fx4woOC4d#*qz3DN23BIJ)JoEM1> zqQ9y7_k1*58?qY9+^9I1gV zw|fX}dZM&vMuP5K3uCzH&rUwkM6=53dPxB1hUFRxzjJaL8XPKgHYBlqOQ48|`3gx| zTI4&V@zf2hP?Gt$Rd}~^`BE@V{&~{GaJJx1<`MeQ_6+%6z}rLLoL{ao>s;LT2psH> zdePs+>$8C140A?)LK8Y2Djpb=tv+Cs_0Q8AQa^QvgmgWQ%#X7$K(&yX$<2cR%JR@8=YMgBt69F)plH%s6D z^BUrwnYDT;e#tBhd{(mXkydNI4x!B$ECj#e)bV@QRRRzTxvZA|Q}h}!|7QP#2DL)N za`xk- z+hUDGrTX@TQmJeBB(11>-qKdy>tATbW^1-phFd?#Dq#gJ)E7=aCEnup{0m_mCm|v$ zCd7VRtN>SKr>jT|=I~xCQ9x7~(`n)(YG7t`h$lBqwVLdPsdFl~ znfcV&Ai^vSbAj>dYIMVdV-yo3qh^0S`Sv=PtMz=r2dI_|zH5n@vBIaeSUnqzY?~U0 zd>Jz*inh2c{k|}q8k%d2AMGTT7;tTvnTr~XlWBDNw27YkI6ydYCak+u6t2&r7hm}8 zbijR9@vu0#$Q+@qnox1u-t7?Z$+6oUU)&|M#XbS-GL|uu`*`RIQ+<#)P$|w>^}R^z zYn6d=g~H_eX-PH^NpZw{HU=nM1hfPZJjwz}av~?|qdv0sEvPPhmawV7?FXQ<6`<@< zhZgHwn50CkouqU2Subc6D40rJ#l7saEMl)_#%y4MAVU+fmYr(vX;aSR2G+N=YRa$+ zXyZ#)1~fNH=x0VgNX1ohg5*>Mc8Lsv-L{s@7)tm-N;S*&lHYd{I|dRD^;VuMM7dm1 zj%f(abCVD}#y}IZS+*AEYSG3Wa#=91MNlxVwn^;0K!D8^tzY$LV67R%ZCZ{$o@fRt z^W(q||4nEtvG2ky$i9D0k9|$L$z$Wkzk5rR&|7gV_Oq;UTQdf^(%<(sW^kC*jzANa z_p`_<88Z?Pcg-069oib$J+@2^d#6=rO%aA|D$0!T{+w)$eJB`(u^{WOQXV$&2L zw$&H*U1=$#QD|wq9eJmt&B1(mdeE9Y>C8!svCLiK_jn9_Zsw-!LAM|WkA3fLFK5G} zGuets<@d$qSEra(aWE%ra7QEI4PWo(lF}XyX?K}a8*u|v9oKo5QR{YP9`7xtFn8a|80CKZ_t?UPki6RmlvTv{hqLxYN zQ%`+rF3UKaN0$}zyG>iH^|mz37-hKB7eU9K4G5J65x?LN(=5t&3dbSSykkWGjSSC9 zmi{MNWgQ5t97&gH3;Ro$P_n5{`PT|Z)jfl|Twa5P_{MK_$e&%=KuG6@?n|B&Had>Y z7=hJxoNF0^WT~0GeC-tvIiL_YI4Z=QZ@A;x!Mu1Ku{RsIIR+|^Hv1mvep*aM8o2zY z>DP=X#pw*_hj-Sim*O&)v8|qsGfC+oR!NKY6-}W8i)bkAD>bJnLu>A>0y6FF$HBn^ zB{HOICL7k%uf-)t#|?pr{aPyOnlbf{C45c!?P(LKY$Sb~2R$-1%to$08;C&(2|-<9 z*N;t-&wRTUM{YY8>XweSIAZK<%?mZrVX{{#<&zUWByqbOD;e|`Ou1nb<*ogn2h3y` z#Zf^)GoU>OBu=Y1rvc+7HfS$-W>PFHD_Vu!?pbK|+2G`(q+apb2`l`}@bB-?OZWJm zwTii`4=hZxRt{nxGUPu&mE$&tO}ro0XtpkAf=3u(==B}4pYyik97r0TTMuoMUzNHq z_v8q(9$VY2WH(%`c;V~3LKYX`(YZ(4uT{&62h~e*gRNJN`dB?3tiCV{&yK^}`ig!e zEZ(R8=)?0dg*w8Hke4Do@~5k{bIHqD;MG-OO|VvVu<{zA@K zXM_09muk)+SegWUeOyD!L;(|(bMvFSFET3;POV=~UCk{kC9A!>NNdKMeG3-@tUM|; zWw@6g2wHE0U_plLO&0)7@OWQuuMf>PCTP7wVKZXDCm^SI@-`$E8`YkoDb5X$`_>Rh z`tIL@g5>(`&UacQ2PpQb#hdN0^8EXHsz77-C6K*DL{IQ?@zT%pW(En_q z|GoFse~heXu35RFGbHtvk$ZVF}eEJlgYn zNW?}EXZL`wrBUg7NAbdY$npAQ=}x5BVFvx5jsRc@=Jp_<^UMdd2ynQ2T2k3DGcEg5 zb>P~pbFI`jgi1D+?K|O+3Ki}^EOWxVzPpN?!L={VZVaqd1Kbu7m`A8;LGqm!beR78 z+1ftDb#dq7ys~>_mK))}_{CvkQd+YOW^EQC*{E|7^9dz?gJ8|aT7-I=M+q9`>oCA$ z*5Q7Gp~%gQ#%A6RpcTCdxaiFGC8X*QA7IwT$#I1k8m_Vd?`ceY5N$9 z8NQ>kZ*{O(v)3j=FW~Q@a%}m#uqJM!3t=8U_$WbIMx3>(5YbnLwnVMQ+<(!2!`T7r zVDXuQC>sAGmYwh~-@Yijnbbey-PoO+mFg^~<2{>|X1~7>jjV38(CGpLf8q}e?J1|KBT*_eh=h7pkJf2 zPf$CJsi?fr-2kfEFPqUH#oXdLRKWFORy5PWw!PuUx~nTD29~TTw)kIdfy!P7@W?L_ z8HpWxj%(0EbzFbj^}&LtKUEw&{jG$(Lg?k4yq&)^3{r^utSvW_$TiG3t7u<&-JJvTL)x}nMOoLiEsm^|0yCV3F5{04_c{z zxyV7WW7v+y-5u`pp|lrruX`gG`$) z^-RHSqGtTdEiO}!g_?)TbCF(zJ^7L8?XF7guSA&VFBC)V-$k#M=EOgDJEz=& z8P1q^x-F*oC-xazCqL{~YPIzZ&P-!z58y_kK~IKv2*k^mG0PK&NxxwCB7UaJn&0 z5~cah+9T!jPvybBa=_~nzM*z$aV(=1g`GZ)O@Qbszv5#V6Kk#MwhA6(Vd>9MQ?&pd zXf1`*p57{Ws{SAdi3m+kVdBHi800Uv7yMxL)5CmPpgV804qX~f*ji1bbdag5EUPb; zQQ87S&6Le+CVlpW+mvUfY;p^WC#4&e9gibqSK`S(GU4nB8W-^29&@!TcP@gALso3t z8YTg`K2pj;LK5qnF!Pkc>Q#>?;~#HIy(W6>)s?YZ-u3wR(T87Y8AcOU8A1X6(HkLN zO}J2fbA7m!Qg9E?g^$0FUclM$UC1lSPKb91o@HX7jsQ?492O9^m~QDq+!}@}buwaB zb)pTq&p$-(!fq1fCr7j1=sy$H)^frh%wcu@i_R*)^Mfg`7nR~(_T6~P z@DN&En7vJ=_4}fUBcMg~^T{&%`#Rv0*FJQ~vQO2_9Kfw1v%3xNG}OY{m>Ii0_dz7& zi`8U~Z7K~ucc35Tw|gk-sYFFHI5ge0Uav%C9C0MdvM4kAQZlwO$#|JCASBmIR~{}% zuTa~+EdPHfSZkAiGZ{A7XE)0}Y=AE}1 zAMV5eTbk6Vh#hil*-rec)yhQtRXJ7Z&yo!%%O#QS`)h4bJlwo=>~{dkgl}zf0=&U+ zD;`<8C40})IQCIcO!S_&-VKXXSle*7)DRK5if!dZ+yi{=z=|uDa_99`TxakHwO$72D%M4;_UL1g&w|Eit#ksx{l3g7DpQe&+s6eYNAzOqwG(P*B7&5Z14|ZS-i4E*|-J;pVbLd z+d9s;>3fbIdf_6g51gYt{e1X-Ck;au$`8(0Wret_yRfnMgIO;cXd!QA?(JpvIj}z5 zFc0XI=GBi7b~)4-4-kOHQ6NynB(JbuUqFN)=kT8pzYe$b^ToYOV?;j#jJ0J2r-Giy zzDC5D(xqHdI5&%#j~wG?$lvvPc@g{dN~|Gwk4yoGt4Y#> zEN+^VALK|0G~X|YWTc`IvMy-nc*8BP(M(V&pZgEME(h6IL}5Zhc;u?1<`=R$W5q84 z+yx{OM?1N3_Qx18qh)>7VqJ4nAMcEbuP^w(ZpsHWVE@!&>*M+b)BNPrY1F(0poE56 zukudBA<*heQda64>=|aAUe%cQ9BC4P#wQLNd=N)JThyhVw5mf0+j7PV_Huq%$MZRT ztuJ6zu)aTTL`bB;PNuJ{9PU8+=K1>1g|xEKmaL)QkO<)`1cK7T))TqddHK!Trc7<- z4Y;)VXPHkrs-LTgqu(xCrj`1uCHVHa-vNV_cD9TdDggC$h&FfL?R~D8cjhMxHG(1*-@Uz zrOzjo)ahmKwamr6*kgGhj(@vUz^+mtPUG-N++SoOTKZ~sy9%?n~)H$KrDt3LK}x@qoqdJj9TFT jTsP^0a6$ZMZM|_#NiA;R7fI7!q99d8ErpubZ$JGHX)GJu literal 0 HcmV?d00001 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()] });