From 6c632be121b8c5e3c88ae1fad4e435b2a6772bcf Mon Sep 17 00:00:00 2001 From: pablonete Date: Wed, 24 Jun 2026 23:27:54 +0200 Subject: [PATCH 1/9] Make DB the only server storage backend Remove Blob, file, and dual-write storage paths now that the app can aggressively reset non-production data. Configure Netlify and Node runtimes to use the DB store directly, keep reset tooling DB-only, remove stale seed copies, and update docs for the DB-only storage model. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 42 +- docs/storage-json.md | 88 +--- netlify.toml | 1 - netlify/functions/api.ts | 62 +-- package-lock.json | 533 +----------------------- package.json | 5 +- server/access-tokens.ts | 98 +---- server/blob-store.ts | 401 ------------------ server/data/conference.json | 5 - server/data/kids.json | 23 - server/data/passportActivities.json | 227 ---------- server/data/prizeAwards.json | 35 -- server/data/prizes.json | 37 -- server/db-store.ts | 205 +++------ server/dual-store.ts | 225 ---------- server/index.ts | 6 + server/{reset-stores.ts => reset-db.ts} | 37 +- server/store.ts | 480 +-------------------- 18 files changed, 143 insertions(+), 2367 deletions(-) delete mode 100644 server/blob-store.ts delete mode 100644 server/data/conference.json delete mode 100644 server/data/kids.json delete mode 100644 server/data/passportActivities.json delete mode 100644 server/data/prizeAwards.json delete mode 100644 server/data/prizes.json delete mode 100644 server/dual-store.ts rename server/{reset-stores.ts => reset-db.ts} (67%) diff --git a/README.md b/README.md index 2cdfa87..0301672 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,8 @@ runs linting, builds the app, uploads the Pages artifact, and deploys from ### Alternate Node/Netlify deployment The static GitHub Pages deployment remains unchanged. For a stateful local Node -deployment, build both the frontend and server, then run the compiled HTTP -server: +deployment, set `NETLIFY_DATABASE_URL`, build both the frontend and server, then +run the compiled HTTP server: ```bash npm run build:node @@ -50,31 +50,23 @@ npm run start:node `VITE_BASE_PATH=/`, and `VITE_API_BASE_URL=/api`, so the same React app uses the Node endpoints instead of bundled mutable sample data. The Node server serves `dist` with SPA fallback and exposes JSON endpoints at `/api/passport`, -`/api/kids`, `/api/wheel-prizes`, and `/api/prizes-kid`. It stores writable event data in -`server/data`, seeded from `src/data` when files are missing. Set -`KID_A_DATA_DIR` to use a different local data directory. +`/api/kids`, `/api/wheel-prizes`, and `/api/prizes-kid`. `netlify.toml` also routes those endpoints to `netlify/functions/api.ts`. -Netlify Functions use Netlify Blobs for durable production writes while keeping -the same frontend API contract. On first read, the blob store is seeded from the -committed JSON data in `server/data` or `src/data`. Passports are stored as one -blob per kid at `passports/{kidId}.json`, so completing an activity only writes -that kid's passport. Prize catalog settings are stored in one shared blob, and -prize awards are stored by kid and exposed through kid-scoped API responses. - -The default blob store name is `kid-a-data`. Set `KID_A_BLOBS_STORE` in Netlify -to use a different store name. Netlify automatically provides the Blobs runtime -context to the function; local Node deployments continue to use `server/data` -and `KID_A_DATA_DIR`. Staff magic-link tokens are role-scoped and stored as -SHA-256 hashes in -the same blob store under `admin/magic-tokens.json`, or in -`server/data/magicTokens.json` for local Node. Set `ADMIN_PASSWORD` to enable -the `/admin` page to generate 1-day desk, wheel, or activity-specific lead -links by default; the duration in days can be changed when generating a link. The -`build:gh-pages` static deployment still uses bundled sample data and does not -call the remote endpoints; it exposes built-in demo links for the same roles. -See [`docs/storage-json.md`](docs/storage-json.md) for the JSON storage layout -and concurrency notes. +Netlify Functions and the local Node server use Netlify DB/Postgres for writable +state and staff magic-link token hashes. Apply the schema and reset +non-production data from committed seed JSON with: + +```bash +NETLIFY_DATABASE_URL=... npm run data:reset-db +``` + +Set `ADMIN_PASSWORD` to enable the `/admin` page to generate 1-day desk, wheel, +or activity-specific lead links by default; the duration in days can be changed +when generating a link. The `build:gh-pages` static deployment still uses +bundled sample data and does not call the remote endpoints; it exposes built-in +demo links for the same roles. See [`docs/storage-json.md`](docs/storage-json.md) +for the DB storage layout. Set `KID_A_ADMIN_TOKEN` to enable protected admin backup and restore endpoints. The export includes `exportedAt`, `passports`, `wheelPrizes`, and `prizesWon`. diff --git a/docs/storage-json.md b/docs/storage-json.md index f2bf426..206c4cd 100644 --- a/docs/storage-json.md +++ b/docs/storage-json.md @@ -1,77 +1,33 @@ -# Storage JSON reference +# Storage reference -This app has two writable storage backends: +The app uses Netlify DB/Postgres as the only writable server-side storage +backend. Committed JSON files in `src/data` are demo seed data and can be used to +reset a non-production DB. -- Local Node deployments use JSON files in `server/data`, seeded from `src/data` - when files are missing. -- Netlify deployments use Netlify Blobs in the `kid-a-data` store by default, - seeded from committed JSON files in `server/data` or `src/data`. -- Netlify deployments can use Netlify DB/Postgres by setting - `KID_A_STORE_BACKEND=db`. -- Netlify deployments can read from one backend and dual-write to Blobs and DB - by setting `KID_A_STORE_BACKEND=dual`. `KID_A_STORE_READ=blob` keeps Blob - reads while shadow-writing DB; `KID_A_STORE_READ=db` reads from DB instead. - Magic-link tokens always follow the same backend mode as app data. +## Runtime data -Netlify Blobs are optimized for reads and infrequent writes. Avoid -read-modify-write on shared JSON blobs for high-frequency event data because -same-key writes are last-write-wins and reads may be stale. - -Server code should write through the command-oriented store methods in -`server/store.ts` instead of calling generic snapshot mutators from API -handlers. This keeps IDs and timestamps explicit where needed and prepares the -app for DB and dual-write backends. - -## Current documents - -| Data | Local Node JSON | Netlify Blob key | Pattern | Notes | -| --- | --- | --- | --- | --- | -| Conference settings | `server/data/conference.json` | `conference.json` | Common document | Static event metadata such as title and kid ID prefix. | -| Kids | `server/data/kids.json` | `kids.json` | Common document | Writable today. Registration sends the client's last known kid ID and retries if a stale read would reuse it. Future work should move this to per-kid blobs. | -| Passport activities | `server/data/passportActivities.json` | `passports/{kidId}.json` | Per kid | Netlify writes only the changed kid passport. The local JSON remains an aggregate map keyed by kid ID. | -| Wheel prize catalog | `server/data/wheel-prizes.json` | `wheel-prizes.json` | Common document | Shared prize settings and stock cache. Writes are infrequent admin/wheel operations. | -| Prize awards | `server/data/prizes-won.json` | `prizes-kid/{kidId}.json` | Per kid | `/prizes-kid` writes only the selected kid's awards. Netlify still reads legacy `prizes-won.json` for seed/admin compatibility and dedupes by award ID. | -| Magic link tokens | `server/data/magicTokens.json` | `admin/magic-tokens.json` | Common document | Stores SHA-256 token hashes and role scopes. | -| Seed marker | N/A | `seeded-v1.json` | Common marker | Marks that the Netlify blob store was seeded. | - -## API write patterns - -| Endpoint | Writes | Current pattern | +| Data | DB table | Notes | | --- | --- | --- | -| `POST /kids` | Kids and initial passport | Common `kids.json` plus per-kid passport. Uses `lastKnownKidId` retry to avoid reusing the ID seen by the client. | -| `POST /passport` | One kid passport | Per kid `passports/{kidId}.json`. | -| `POST /prizes-kid` | One kid's prize awards | Per kid `prizes-kid/{kidId}.json`. | -| `POST /wheel-prizes` | Prize catalog | Common `wheel-prizes.json`. | -| `POST /admin/import` | Passports, awards, catalog | Bulk restore. Rewrites local aggregates and refreshes per-kid blob mirrors where supported. Treat as maintenance-mode only. | -| `POST /admin/magic-links` | Magic tokens | Common `admin/magic-tokens.json`. | +| Conference settings | `conference_settings` | Singleton event metadata such as title and kid ID prefix. | +| Kids | `kids` | Kid IDs are app-owned strings; the DB does not use generated user-facing IDs. | +| Passport activities | `passport_activities` | Stores the per-kid passport rows and optional completion timestamp. | +| Wheel prize catalog | `prizes` | Stores prize settings. `given` is derived from awards. | +| Prize awards | `prize_awards` | Stores awarded prizes and source metadata. | +| Magic link tokens | `magic_link_tokens` | Stores SHA-256 token hashes and role scopes. | -## Follow-up migration targets +## Resetting non-production data -1. Reset Blob and DB writable state from committed seed JSON. -2. Enable config-driven dual writes with Blob reads. -3. Switch reads to DB after live verification. -4. Keep blobs as a rollback target until DB reads are verified. - -## Netlify DB schema - -The first DB migration lives in `db/migrations/0001_netlify_db.sql`. It keeps -app-owned IDs for kids, prizes, prize awards, and magic-link token hashes; the -database does not auto-generate user-facing IDs or maintain a separate kid ID -counter table. The DB adapter derives prize `given` counts from `prize_awards` -and builds passport responses from `passport_activities` rows. - -The app is not in production yet, so existing Blob and DB testing data can be -discarded. To apply the DB schema, reset both stores from committed seed JSON, -and clear magic-link tokens in both places, run: +The app is not in production yet, so testing data can be discarded. To apply the +DB schema, reset writable data from committed seed JSON, and clear magic-link +tokens, run: ```sh -NETLIFY_DATABASE_URL=... npm run data:reset-stores +NETLIFY_DATABASE_URL=... npm run data:reset-db ``` -After reset, start the rollout with Blob reads and dual writes: +## Schema -```sh -KID_A_STORE_BACKEND=dual -KID_A_STORE_READ=blob -KID_A_DUAL_WRITE_STRICT=true -``` +The DB schema lives in `db/migrations/0001_netlify_db.sql`. It keeps app-owned +IDs for kids, prizes, prize awards, and magic-link token hashes. The database +does not auto-generate user-facing IDs or maintain a separate kid ID counter +table. diff --git a/netlify.toml b/netlify.toml index b60dad9..0c3a307 100644 --- a/netlify.toml +++ b/netlify.toml @@ -4,7 +4,6 @@ [functions] directory = "netlify/functions" - included_files = ["server/data/*.json", "src/data/*.json"] [[redirects]] from = "/api/passport" diff --git a/netlify/functions/api.ts b/netlify/functions/api.ts index 6c322ab..c8e8fe5 100644 --- a/netlify/functions/api.ts +++ b/netlify/functions/api.ts @@ -1,13 +1,9 @@ -import { connectLambda } from '@netlify/blobs'; -import { createBlobMagicTokenStore, setMagicTokenStore } from '../../server/access-tokens.js'; +import { setMagicTokenStore } from '../../server/access-tokens.js'; import { handleApiRequest } from '../../server/api.js'; -import { createBlobStore } from '../../server/blob-store.js'; import { createDbMagicTokenStore, createDbStore } from '../../server/db-store.js'; -import { createDualMagicTokenStore, createDualStore } from '../../server/dual-store.js'; import { setStoreAdapter } from '../../server/store.js'; type NetlifyEvent = { - blobs?: string; body?: string | null; headers?: Record; httpMethod: string; @@ -15,64 +11,12 @@ type NetlifyEvent = { path: string; }; -function definedHeaders(headers: NetlifyEvent['headers']) { - return Object.fromEntries( - Object.entries(headers ?? {}).filter( - (entry): entry is [string, string] => entry[1] !== undefined, - ), - ); -} - function configureStores() { - const storeBackend = process.env.KID_A_STORE_BACKEND ?? 'blob'; - const storeRead = process.env.KID_A_STORE_READ ?? 'blob'; - const strictDualWrites = process.env.KID_A_DUAL_WRITE_STRICT !== 'false'; - - if (storeBackend !== 'blob' && storeBackend !== 'db' && storeBackend !== 'dual') { - throw new Error('KID_A_STORE_BACKEND must be blob, db, or dual'); - } - - if (storeRead !== 'blob' && storeRead !== 'db') { - throw new Error('KID_A_STORE_READ must be blob or db'); - } - - if (storeBackend === 'dual') { - const blobStore = createBlobStore(); - const dbStore = createDbStore(); - const blobTokenStore = createBlobMagicTokenStore(); - const dbTokenStore = createDbMagicTokenStore(); - const primary = storeRead === 'db' ? dbStore : blobStore; - const secondary = storeRead === 'db' ? blobStore : dbStore; - const primaryTokenStore = storeRead === 'db' ? dbTokenStore : blobTokenStore; - const secondaryTokenStore = storeRead === 'db' ? blobTokenStore : dbTokenStore; - - setStoreAdapter( - createDualStore({ primary, secondary, strict: strictDualWrites }), - ); - setMagicTokenStore( - createDualMagicTokenStore({ - primary: primaryTokenStore, - secondary: secondaryTokenStore, - strict: strictDualWrites, - }), - ); - return; - } - - setStoreAdapter(storeBackend === 'db' ? createDbStore() : createBlobStore()); - setMagicTokenStore( - storeBackend === 'db' ? createDbMagicTokenStore() : createBlobMagicTokenStore(), - ); + setStoreAdapter(createDbStore()); + setMagicTokenStore(createDbMagicTokenStore()); } export async function handler(event: NetlifyEvent) { - if (event.blobs) { - connectLambda({ - blobs: event.blobs, - headers: definedHeaders(event.headers), - }); - } - configureStores(); const response = await handleApiRequest({ body: event.body, diff --git a/package-lock.json b/package-lock.json index d984e6b..d97ff2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "kid-a", "version": "0.0.0", "dependencies": { - "@netlify/blobs": "^10.7.9", "@netlify/neon": "^0.1.2", "@primer/octicons-react": "^19.28.1", "@types/qrcode": "^1.5.6", @@ -65,19 +64,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@envelop/instrumentation": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@envelop/instrumentation/-/instrumentation-1.0.0.tgz", - "integrity": "sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==", - "license": "MIT", - "dependencies": { - "@whatwg-node/promise-helpers": "^1.2.1", - "tslib": "^2.5.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -206,12 +192,6 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@fastify/busboy": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", - "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", - "license": "MIT" - }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -306,45 +286,6 @@ "node": ">=19.0.0" } }, - "node_modules/@netlify/blobs": { - "version": "10.7.9", - "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-10.7.9.tgz", - "integrity": "sha512-NEM8aNAMZCCWBWymomMM/fn5wmPGyGE8pendgsSu5mL7UNz+aXqGcHS0MiHWU/osyg+geiZuS+Rx956SVrMM/w==", - "license": "MIT", - "dependencies": { - "@netlify/dev-utils": "4.4.6", - "@netlify/otel": "^6.0.3", - "@netlify/runtime-utils": "2.3.0" - }, - "engines": { - "node": "^14.16.0 || >=16.0.0" - } - }, - "node_modules/@netlify/dev-utils": { - "version": "4.4.6", - "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-4.4.6.tgz", - "integrity": "sha512-P6X+xS3glvhiTX6AAocYvnZtqXtWhvxMX4AdPgv2iw6cXjGL2criUveX7bSjp6DiyHfvQpNdG0ZRpeBEVAFf1g==", - "license": "MIT", - "dependencies": { - "@whatwg-node/server": "^0.10.0", - "ansis": "^4.1.0", - "atomically": "^2.0.3", - "chokidar": "^4.0.1", - "decache": "^4.6.2", - "dettle": "^1.0.5", - "dot-prop": "9.0.0", - "empathic": "^2.0.0", - "env-paths": "^3.0.0", - "image-size": "^2.0.2", - "js-image-generator": "^1.0.4", - "parse-gitignore": "^2.0.0", - "semver": "^7.7.2", - "tmp-promise": "^3.0.3" - }, - "engines": { - "node": "^18.14.0 || >=20" - } - }, "node_modules/@netlify/neon": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@netlify/neon/-/neon-0.1.2.tgz", @@ -354,155 +295,6 @@ "@neondatabase/serverless": "1.x" } }, - "node_modules/@netlify/otel": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@netlify/otel/-/otel-6.0.3.tgz", - "integrity": "sha512-NIjIjB/aItiXKB6+wzSwfSyYqbNsVSzzlEPryxxTC5ZJiYSYSm82wAOcQ+9VdiufQyZO5t8jzHVPULo7a9L9sQ==", - "license": "MIT", - "dependencies": { - "@opentelemetry/api": "1.9.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/instrumentation": "^0.217.0", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/sdk-trace-node": "2.7.1" - }, - "engines": { - "node": "^18.14.0 || >=20.6.1" - } - }, - "node_modules/@netlify/runtime-utils": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@netlify/runtime-utils/-/runtime-utils-2.3.0.tgz", - "integrity": "sha512-cW8weDvsKV7zfia2m5EcBy6KILGoPD+eYZ3qWNGnIo05DGF28goPES0xKSDkNYgAF/2rRSIhie2qcBhbGVgSRg==", - "license": "MIT", - "engines": { - "node": "^18.14.0 || >=20" - } - }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/api-logs": { - "version": "0.217.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.217.0.tgz", - "integrity": "sha512-Cdq0jW2lknrNfrAm92MyEAvpe2cRsKjdnQLHUL6xRA4IVUnsWx6P65E7NcUO0Y+L4w1Aee5iV8FvjSwd+lrs9A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/context-async-hooks": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.7.1.tgz", - "integrity": "sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ==", - "license": "Apache-2.0", - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/core": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", - "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/instrumentation": { - "version": "0.217.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.217.0.tgz", - "integrity": "sha512-24ucQMjz7Y34Kw3trbxL2ZrssbtgWnR+Clpaa+YdeWuuyH3Cvk23Q03PcQvqiZrDvt8AmQmjgg9v6Y9PHoxG7w==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.217.0", - "import-in-the-middle": "^3.0.0", - "require-in-the-middle": "^8.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/resources": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", - "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", - "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-node": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.7.1.tgz", - "integrity": "sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/context-async-hooks": "2.7.1", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/sdk-trace-base": "2.7.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.41.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", - "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -1115,79 +907,11 @@ } } }, - "node_modules/@whatwg-node/disposablestack": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz", - "integrity": "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==", - "license": "MIT", - "dependencies": { - "@whatwg-node/promise-helpers": "^1.0.0", - "tslib": "^2.6.3" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@whatwg-node/fetch": { - "version": "0.10.13", - "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.13.tgz", - "integrity": "sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==", - "license": "MIT", - "dependencies": { - "@whatwg-node/node-fetch": "^0.8.3", - "urlpattern-polyfill": "^10.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@whatwg-node/node-fetch": { - "version": "0.8.6", - "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.8.6.tgz", - "integrity": "sha512-BDMdYFcerLQkwA2RTldxOqRCs6ZQD1S7UgP3pUdGUkcbgTrP/V5ko77ZkCww9DHmC4lpoYuwigGfQYj285gMvA==", - "license": "MIT", - "dependencies": { - "@fastify/busboy": "^3.1.1", - "@whatwg-node/disposablestack": "^0.0.6", - "@whatwg-node/promise-helpers": "^1.3.2", - "tslib": "^2.6.3" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@whatwg-node/promise-helpers": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", - "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.6.3" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@whatwg-node/server": { - "version": "0.10.18", - "resolved": "https://registry.npmjs.org/@whatwg-node/server/-/server-0.10.18.tgz", - "integrity": "sha512-kMwLlxUbduttIgaPdSkmEarFpP+mSY8FEm+QWMBRJwxOHWkri+cxd8KZHO9EMrB9vgUuz+5WEaCawaL5wGVoXg==", - "license": "MIT", - "dependencies": { - "@envelop/instrumentation": "^1.0.0", - "@whatwg-node/disposablestack": "^0.0.6", - "@whatwg-node/fetch": "^0.10.13", - "@whatwg-node/promise-helpers": "^1.3.2", - "tslib": "^2.6.3" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1196,15 +920,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", - "license": "MIT", - "peerDependencies": { - "acorn": "^8" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -1256,25 +971,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/ansis": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz", - "integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==", - "license": "ISC", - "engines": { - "node": ">=14" - } - }, - "node_modules/atomically": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", - "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", - "license": "MIT", - "dependencies": { - "stubborn-fs": "^2.0.0", - "when-exit": "^2.1.4" - } - }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1298,14 +994,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/callsite": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz", - "integrity": "sha512-0vdNRFXn5q+dtOqjfFtmtlI9N2eVZ7LMyEV2iKC5mEEFvSg/69Ml6b/WU2qF8W1nLRa0wiSrDT3Y5jOHZCwKPQ==", - "engines": { - "node": "*" - } - }, "node_modules/camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", @@ -1315,27 +1003,6 @@ "node": ">=6" } }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/cjs-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", - "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", - "license": "MIT" - }, "node_modules/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -1404,6 +1071,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1417,15 +1085,6 @@ } } }, - "node_modules/decache": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/decache/-/decache-4.6.2.tgz", - "integrity": "sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==", - "license": "MIT", - "dependencies": { - "callsite": "^1.0.0" - } - }, "node_modules/decamelize": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", @@ -1452,60 +1111,18 @@ "node": ">=8" } }, - "node_modules/dettle": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/dettle/-/dettle-1.0.5.tgz", - "integrity": "sha512-ZVyjhAJ7sCe1PNXEGveObOH9AC8QvMga3HJIghHawtG7mE4K5pW9nz/vDGAr/U7a3LWgdOzEE7ac9MURnyfaTA==", - "license": "MIT" - }, "node_modules/dijkstrajs": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "license": "MIT" }, - "node_modules/dot-prop": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", - "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", - "license": "MIT", - "dependencies": { - "type-fest": "^4.18.2" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/empathic": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz", - "integrity": "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1821,33 +1438,6 @@ "node": ">= 4" } }, - "node_modules/image-size": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", - "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", - "license": "MIT", - "bin": { - "image-size": "bin/image-size.js" - }, - "engines": { - "node": ">=16.x" - } - }, - "node_modules/import-in-the-middle": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.1.0.tgz", - "integrity": "sha512-c0AeAV8VcwZzfYE7euTZY3H+VXUPMVugiovdosq80lqEXJmOekg3zGUAYg6KImHMaMuBoTUfTv7xNpUFdy0hJA==", - "license": "Apache-2.0", - "dependencies": { - "acorn": "^8.15.0", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^2.2.0", - "module-details-from-path": "^1.0.4" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -1897,21 +1487,6 @@ "dev": true, "license": "ISC" }, - "node_modules/jpeg-js": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", - "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", - "license": "BSD-3-Clause" - }, - "node_modules/js-image-generator": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/js-image-generator/-/js-image-generator-1.0.4.tgz", - "integrity": "sha512-ckb7kyVojGAnArouVR+5lBIuwU1fcrn7E/YYSd0FK7oIngAkMmRvHASLro9Zt5SQdWToaI66NybG+OGxPw/HlQ==", - "license": "ISC", - "dependencies": { - "jpeg-js": "^0.4.2" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2256,16 +1831,11 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/module-details-from-path": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", - "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -2353,15 +1923,6 @@ "node": ">=6" } }, - "node_modules/parse-gitignore": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/parse-gitignore/-/parse-gitignore-2.0.0.tgz", - "integrity": "sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2535,19 +2096,6 @@ "react-dom": ">=18" } }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2557,19 +2105,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-in-the-middle": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", - "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "module-details-from-path": "^1.0.3" - }, - "engines": { - "node": ">=9.3.0 || >=8.10.0 <9.0.0" - } - }, "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -2620,6 +2155,7 @@ "version": "7.8.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2699,21 +2235,6 @@ "node": ">=8" } }, - "node_modules/stubborn-fs": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", - "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", - "license": "MIT", - "dependencies": { - "stubborn-utils": "^1.0.1" - } - }, - "node_modules/stubborn-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", - "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", - "license": "MIT" - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2731,24 +2252,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tmp": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", - "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, - "node_modules/tmp-promise": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", - "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", - "license": "MIT", - "dependencies": { - "tmp": "^0.2.0" - } - }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -2766,7 +2269,9 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "dev": true, + "license": "0BSD", + "optional": true }, "node_modules/type-check": { "version": "0.4.0", @@ -2781,18 +2286,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -2847,12 +2340,6 @@ "punycode": "^2.1.0" } }, - "node_modules/urlpattern-polyfill": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", - "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", - "license": "MIT" - }, "node_modules/vite": { "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", @@ -2931,12 +2418,6 @@ } } }, - "node_modules/when-exit": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", - "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", - "license": "MIT" - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 84fdaff..8b8877b 100644 --- a/package.json +++ b/package.json @@ -9,14 +9,13 @@ "build:gh-pages": "npm run build", "build:netlify": "VITE_DATA_LAYER=remote VITE_BASE_PATH=/ VITE_API_BASE_URL=/api npm run build && npm run build:server", "build:node": "VITE_DATA_LAYER=remote VITE_BASE_PATH=/ VITE_API_BASE_URL=/api npm run build && npm run build:server", - "build:server": "tsc -p tsconfig.server.json", - "data:reset-stores": "npm run build:server && node dist-server/server/reset-stores.js", + "build:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\" && tsc -p tsconfig.server.json", + "data:reset-db": "npm run build:server && node dist-server/server/reset-db.js", "lint": "eslint .", "preview": "vite preview", "start:node": "node dist-server/server/index.js" }, "dependencies": { - "@netlify/blobs": "^10.7.9", "@netlify/neon": "^0.1.2", "@primer/octicons-react": "^19.28.1", "@types/qrcode": "^1.5.6", diff --git a/server/access-tokens.ts b/server/access-tokens.ts index 61a3255..2c736e5 100644 --- a/server/access-tokens.ts +++ b/server/access-tokens.ts @@ -1,7 +1,4 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; -import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { getStore, type Store as NetlifyBlobStore } from '@netlify/blobs'; import type { UserRole } from './types.js'; export type MagicLinkTokenRecord = { @@ -24,21 +21,11 @@ export type MagicLinkScope = { }; export type MagicLinkTokenStore = { + appendToken(token: MagicLinkTokenRecord): Promise; readTokens(): Promise; writeTokens(tokens: MagicLinkTokenRecord[]): Promise; }; -const defaultBlobStoreName = 'kid-a-data'; -const magicTokensBlobKey = 'admin/magic-tokens.json'; -const defaultMagicTokensFile = path.resolve( - process.env.KID_A_MAGIC_TOKENS_FILE ?? - path.join(process.env.KID_A_DATA_DIR ?? 'server/data', 'magicTokens.json'), -); - -function isMissingFileError(error: unknown): error is NodeJS.ErrnoException { - return error instanceof Error && 'code' in error && error.code === 'ENOENT'; -} - function hashToken(token: string) { return createHash('sha256').update(token).digest('hex'); } @@ -66,83 +53,18 @@ function toSession(token: MagicLinkTokenRecord): MagicLinkSession { }; } -function createFileMagicTokenStore(filePath = defaultMagicTokensFile) { - return { - async readTokens() { - try { - return JSON.parse(await readFile(filePath, 'utf8')) as MagicLinkTokenRecord[]; - } catch (error) { - if (isMissingFileError(error)) { - return []; - } - - throw error; - } - }, - async writeTokens(tokens: MagicLinkTokenRecord[]) { - await mkdir(path.dirname(filePath), { recursive: true }); - - const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; - await writeFile(tempPath, `${JSON.stringify(tokens, null, 2)}\n`); - await rename(tempPath, filePath); - }, - } satisfies MagicLinkTokenStore; -} - -export function createBlobMagicTokenStore( - store: NetlifyBlobStore = getStore( - process.env.KID_A_BLOBS_STORE ?? defaultBlobStoreName, - ), -) { - return { - async readTokens() { - const tokens = (await store.get(magicTokensBlobKey, { - type: 'json', - })) as MagicLinkTokenRecord[] | null; - - if (tokens) { - return tokens; - } - - await store.setJSON(magicTokensBlobKey, [], { onlyIfNew: true }); - return []; - }, - async writeTokens(tokens: MagicLinkTokenRecord[]) { - await store.setJSON(magicTokensBlobKey, tokens); - }, - } satisfies MagicLinkTokenStore; -} - -let activeMagicTokenStore: MagicLinkTokenStore = createFileMagicTokenStore(); -let writeQueue: Promise = Promise.resolve(); +let activeMagicTokenStore: MagicLinkTokenStore | undefined; export function setMagicTokenStore(store: MagicLinkTokenStore) { activeMagicTokenStore = store; - writeQueue = Promise.resolve(); } -async function updateMagicTokens( - mutator: (tokens: MagicLinkTokenRecord[]) => T | Promise, -) { - const nextWrite = writeQueue - .catch(() => undefined) - .then(async () => { - const activeTokens = (await activeMagicTokenStore.readTokens()).filter( - isActiveToken, - ); - const result = await mutator(activeTokens); - - await activeMagicTokenStore.writeTokens(activeTokens); - - return result; - }); - - writeQueue = nextWrite.then( - () => undefined, - () => undefined, - ); +function requireMagicTokenStore() { + if (!activeMagicTokenStore) { + throw new Error('Magic token store has not been configured'); + } - return nextWrite; + return activeMagicTokenStore; } export async function createMagicLinkToken( @@ -160,9 +82,7 @@ export async function createMagicLinkToken( tokenHash: hashToken(token), }; - await updateMagicTokens((tokens) => { - tokens.push(record); - }); + await requireMagicTokenStore().appendToken(record); return { ...toSession(record), @@ -180,7 +100,7 @@ export async function validateMagicLinkToken( } const tokenHash = hashToken(token); - const records = await activeMagicTokenStore.readTokens(); + const records = await requireMagicTokenStore().readTokens(); const record = records.find( (entry) => isActiveToken(entry) && constantTimeEquals(entry.tokenHash, tokenHash), ); diff --git a/server/blob-store.ts b/server/blob-store.ts deleted file mode 100644 index b317a87..0000000 --- a/server/blob-store.ts +++ /dev/null @@ -1,401 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; -import { getStore, type Store as NetlifyBlobStore } from '@netlify/blobs'; -import { - getStoreFileName, - runAwardPrizeCommand, - runCompletePassportActivityCommand, - runRegisterKidCommand, - runRestoreWritableDataCommand, - runSavePrizeCommand, - type StoreAdapter, - type StoreFile, -} from './store.js'; -import type { - ConferenceData, - Kid, - PassportActivitiesByKid, - PassportActivity, - Prize, - PrizeAward, - StoreData, -} from './types.js'; - -const defaultBlobStoreName = 'kid-a-data'; -const passportPrefix = 'passports/'; -const prizeAwardsPrefix = 'prizes-kid/'; -const seedMarkerKey = 'seeded-v1.json'; -const jsonDocumentKeys = { - conference: 'conference.json', - kids: 'kids.json', - prizeAwards: 'prizes-won.json', - prizes: 'wheel-prizes.json', -} as const satisfies Partial>; - -function getSeedDataDirs() { - if (process.env.KID_A_SEED_DATA_DIR) { - return [path.resolve(process.env.KID_A_SEED_DATA_DIR)]; - } - - const runtimeRoots = [process.cwd(), process.env.LAMBDA_TASK_ROOT].filter( - (runtimeRoot): runtimeRoot is string => Boolean(runtimeRoot), - ); - - return Array.from( - new Set( - runtimeRoots.flatMap((runtimeRoot) => [ - path.resolve(runtimeRoot, 'server/data'), - path.resolve(runtimeRoot, 'src/data'), - ]), - ), - ); -} - -function isMissingFileError(error: unknown): error is NodeJS.ErrnoException { - return error instanceof Error && 'code' in error && error.code === 'ENOENT'; -} - -async function readSeedJson(fileName: string): Promise { - const attemptedPaths: string[] = []; - - for (const seedDataDir of getSeedDataDirs()) { - const seedPath = path.join(seedDataDir, fileName); - attemptedPaths.push(seedPath); - - try { - return JSON.parse(await readFile(seedPath, 'utf8')) as T; - } catch (error) { - if (isMissingFileError(error)) { - continue; - } - - throw error; - } - } - - throw new Error( - `Missing seed data file ${fileName}; checked ${attemptedPaths.join(', ')}`, - ); -} - -function passportKey(kidId: string) { - return `${passportPrefix}${kidId}.json`; -} - -function kidIdFromPassportKey(key: string) { - return key.slice(passportPrefix.length, -'.json'.length); -} - -function prizeAwardsKey(kidId: string) { - return `${prizeAwardsPrefix}${kidId}.json`; -} - -function kidIdFromPrizeAwardsKey(key: string) { - return key.slice(prizeAwardsPrefix.length, -'.json'.length); -} - -async function readRequiredBlobJson(store: NetlifyBlobStore, key: string) { - const value = (await store.get(key, { - type: 'json', - })) as T | null; - - if (value === null) { - throw new Error(`Missing Netlify Blob document after seed: ${key}`); - } - - return value; -} - -function groupPrizeAwardsByKid(prizeAwards: PrizeAward[]) { - return prizeAwards.reduce>((groups, award) => { - const group = groups[award.kidId] ?? []; - group.push(award); - groups[award.kidId] = group; - return groups; - }, {}); -} - -function dedupePrizeAwards(prizeAwards: PrizeAward[]) { - return Array.from( - new Map(prizeAwards.map((award) => [award.id, award])).values(), - ); -} - -async function writeStoreFile( - store: NetlifyBlobStore, - storeFile: StoreFile, - snapshot: StoreData, -) { - if (storeFile === 'passportActivitiesByKid') { - const expectedKeys = new Set( - Object.keys(snapshot.passportActivitiesByKid).map(passportKey), - ); - const existingKeys: string[] = []; - - for await (const passportList of store.list({ - paginate: true, - prefix: passportPrefix, - })) { - existingKeys.push(...passportList.blobs.map(({ key }) => key)); - } - - await Promise.all( - [ - ...existingKeys - .filter((key) => !expectedKeys.has(key)) - .map((key) => store.delete(key)), - ...Object.entries(snapshot.passportActivitiesByKid).map(([kidId, passport]) => - store.setJSON(passportKey(kidId), passport), - ), - ], - ); - return; - } - - if (storeFile === 'prizeAwards') { - await Promise.all([ - store.setJSON(jsonDocumentKeys.prizeAwards, snapshot.prizeAwards), - ...Object.entries(groupPrizeAwardsByKid(snapshot.prizeAwards)).map( - ([kidId, awards]) => store.setJSON(prizeAwardsKey(kidId), awards), - ), - ]); - return; - } - - await store.setJSON(jsonDocumentKeys[storeFile], snapshot[storeFile]); -} - -async function readSeedSnapshot(): Promise { - const [conference, kids, passportActivitiesByKid, prizeAwards, prizes] = - await Promise.all([ - readSeedJson(getStoreFileName('conference')), - readSeedJson(getStoreFileName('kids')), - readSeedJson( - getStoreFileName('passportActivitiesByKid'), - ), - readSeedJson(getStoreFileName('prizeAwards')), - readSeedJson(getStoreFileName('prizes')), - ]); - - return { - conference, - kids, - passportActivitiesByKid, - prizeAwards, - prizes, - }; -} - -export function createBlobStore( - store = getStore(process.env.KID_A_BLOBS_STORE ?? defaultBlobStoreName), -): StoreAdapter { - let seedPromise: Promise | undefined; - let writeQueue: Promise = Promise.resolve(); - - async function ensureSeeded() { - seedPromise ??= (async () => { - const seedMarker = await store.getMetadata(seedMarkerKey); - - if (seedMarker) { - return; - } - - const seedSnapshot = await readSeedSnapshot(); - - await store.setJSON(jsonDocumentKeys.conference, seedSnapshot.conference); - await store.setJSON(jsonDocumentKeys.kids, seedSnapshot.kids); - await store.setJSON(jsonDocumentKeys.prizeAwards, seedSnapshot.prizeAwards); - await store.setJSON(jsonDocumentKeys.prizes, seedSnapshot.prizes); - - await Promise.all( - Object.entries(groupPrizeAwardsByKid(seedSnapshot.prizeAwards)).map( - ([kidId, awards]) => - store.setJSON(prizeAwardsKey(kidId), awards, { onlyIfNew: true }), - ), - ); - - for (const [kidId, passport] of Object.entries( - seedSnapshot.passportActivitiesByKid, - )) { - await store.setJSON(passportKey(kidId), passport); - } - - await store.setJSON( - seedMarkerKey, - { seededAt: new Date().toISOString(), version: 1 }, - { onlyIfNew: true }, - ); - })(); - - return seedPromise; - } - - async function readPassportActivitiesByKid() { - const passportEntries: Array = []; - - for await (const passportList of store.list({ - paginate: true, - prefix: passportPrefix, - })) { - passportEntries.push( - ...(await Promise.all( - passportList.blobs.map(async ({ key }) => { - const passport = await readRequiredBlobJson(store, key); - return [kidIdFromPassportKey(key), passport] as const; - }), - )), - ); - } - - return Object.fromEntries(passportEntries) as PassportActivitiesByKid; - } - - async function readPrizeAwards() { - const [legacyPrizeAwards, prizeAwardsByKid] = await Promise.all([ - readRequiredBlobJson(store, jsonDocumentKeys.prizeAwards), - (async () => { - const awardEntries: PrizeAward[] = []; - - for await (const prizeAwardsList of store.list({ - paginate: true, - prefix: prizeAwardsPrefix, - })) { - awardEntries.push( - ...(await Promise.all( - prizeAwardsList.blobs.map(async ({ key }) => { - const awards = await readRequiredBlobJson(store, key); - return awards.map((award) => ({ - ...award, - kidId: kidIdFromPrizeAwardsKey(key), - })); - }), - )).flat(), - ); - } - - return awardEntries; - })(), - ]); - - return dedupePrizeAwards([...legacyPrizeAwards, ...prizeAwardsByKid]); - } - - async function readSnapshotUnlocked(): Promise { - await ensureSeeded(); - - const [conference, kids, passportActivitiesByKid, prizeAwards, prizes] = - await Promise.all([ - readRequiredBlobJson(store, jsonDocumentKeys.conference), - readRequiredBlobJson(store, jsonDocumentKeys.kids), - readPassportActivitiesByKid(), - readPrizeAwards(), - readRequiredBlobJson(store, jsonDocumentKeys.prizes), - ]); - - return { - conference, - kids, - passportActivitiesByKid, - prizeAwards, - prizes, - }; - } - - async function readSnapshot() { - await writeQueue.catch(() => undefined); - return readSnapshotUnlocked(); - } - - async function updateSnapshot( - mutator: (snapshot: StoreData) => T | Promise, - changedFiles: readonly StoreFile[], - ) { - const previousWrite = writeQueue; - const nextWrite = previousWrite - .catch(() => undefined) - .then(async () => { - const snapshot = await readSnapshotUnlocked(); - const result = await mutator(snapshot); - - await Promise.all( - changedFiles.map((storeFile) => writeStoreFile(store, storeFile, snapshot)), - ); - - return result; - }); - - writeQueue = nextWrite.then( - () => undefined, - () => undefined, - ); - - return nextWrite; - } - - async function updatePassportForKid( - kidId: string, - mutator: (snapshot: StoreData) => T | Promise, - ) { - const previousWrite = writeQueue; - const nextWrite = previousWrite - .catch(() => undefined) - .then(async () => { - const snapshot = await readSnapshotUnlocked(); - const result = await mutator(snapshot); - const passport = snapshot.passportActivitiesByKid[kidId]; - - if (!passport) { - throw new Error(`Passport update did not produce a passport for ${kidId}`); - } - - await store.setJSON(passportKey(kidId), passport); - - return result; - }); - - writeQueue = nextWrite.then( - () => undefined, - () => undefined, - ); - - return nextWrite; - } - - async function updatePrizeAwardsForKid( - kidId: string, - mutator: (snapshot: StoreData) => T | Promise, - ) { - const previousWrite = writeQueue; - const nextWrite = previousWrite - .catch(() => undefined) - .then(async () => { - const snapshot = await readSnapshotUnlocked(); - const result = await mutator(snapshot); - const awards = snapshot.prizeAwards.filter((award) => award.kidId === kidId); - - await store.setJSON(prizeAwardsKey(kidId), awards); - - return result; - }); - - writeQueue = nextWrite.then( - () => undefined, - () => undefined, - ); - - return nextWrite; - } - - return { - awardPrize: (command) => runAwardPrizeCommand(command, updatePrizeAwardsForKid), - completePassportActivity: (command) => - runCompletePassportActivityCommand(command, updatePassportForKid), - readSnapshot, - registerKid: (command) => runRegisterKidCommand(command, updateSnapshot), - restoreWritableData: (data) => runRestoreWritableDataCommand(data, updateSnapshot), - savePrize: (command) => runSavePrizeCommand(command, updateSnapshot), - updatePassportForKid, - updatePrizeAwardsForKid, - updateSnapshot, - }; -} diff --git a/server/data/conference.json b/server/data/conference.json deleted file mode 100644 index 0fc75c7..0000000 --- a/server/data/conference.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "kidIdPrefix": "26OSK", - "shortName": "OpenSouthKids", - "title": "OpenSouthKids 2026" -} diff --git a/server/data/kids.json b/server/data/kids.json deleted file mode 100644 index 16abf1b..0000000 --- a/server/data/kids.json +++ /dev/null @@ -1,23 +0,0 @@ -[ - { - "id": "26OSK0001", - "name": "Barbie", - "age": 9, - "gender": "girl", - "language": "en" - }, - { - "id": "26OSK0002", - "name": "Ken", - "age": 10, - "gender": "boy", - "language": "en" - }, - { - "id": "26OSK0003", - "name": "Gloria", - "age": 11, - "gender": "girl", - "language": "en" - } -] diff --git a/server/data/passportActivities.json b/server/data/passportActivities.json deleted file mode 100644 index 99eb1b1..0000000 --- a/server/data/passportActivities.json +++ /dev/null @@ -1,227 +0,0 @@ -{ - "26OSK0001": [ - { - "id": 1, - "completedAt": "2026-06-13T09:00:00.000Z" - }, - { - "id": 2, - "completedAt": "2026-06-13T09:08:00.000Z" - }, - { - "id": 3 - }, - { - "id": 4 - }, - { - "id": 5, - "completedAt": "2026-06-13T09:22:00.000Z" - }, - { - "id": 6 - }, - { - "id": 7 - }, - { - "id": 8 - }, - { - "id": 9, - "completedAt": "2026-06-13T09:44:00.000Z" - }, - { - "id": 10 - }, - { - "id": 11 - }, - { - "id": 12 - }, - { - "id": 13, - "completedAt": "2026-06-13T10:05:00.000Z" - }, - { - "id": 14 - }, - { - "id": 15 - }, - { - "id": 16 - } - ], - "26OSK0002": [ - { - "id": 1 - }, - { - "id": 2 - }, - { - "id": 3, - "completedAt": "2026-06-13T09:12:00.000Z" - }, - { - "id": 4, - "completedAt": "2026-06-13T09:29:00.000Z" - }, - { - "id": 5 - }, - { - "id": 6 - }, - { - "id": 7, - "completedAt": "2026-06-13T09:51:00.000Z" - }, - { - "id": 8 - }, - { - "id": 9 - }, - { - "id": 10 - }, - { - "id": 11, - "completedAt": "2026-06-13T10:14:00.000Z" - }, - { - "id": 12 - }, - { - "id": 13 - }, - { - "id": 14 - }, - { - "id": 15 - }, - { - "id": 16 - } - ], - "26OSK0003": [ - { - "id": 1 - }, - { - "id": 2, - "completedAt": "2026-06-13T09:03:00.000Z" - }, - { - "id": 3, - "completedAt": "2026-06-13T09:11:00.000Z" - }, - { - "id": 4, - "completedAt": "2026-06-13T09:19:00.000Z" - }, - { - "id": 5, - "completedAt": "2026-06-13T09:27:00.000Z" - }, - { - "id": 6, - "completedAt": "2026-06-13T09:35:00.000Z" - }, - { - "id": 7, - "completedAt": "2026-06-13T09:43:00.000Z" - }, - { - "id": 8, - "completedAt": "2026-06-13T09:51:00.000Z" - }, - { - "id": 9, - "completedAt": "2026-06-13T09:59:00.000Z" - }, - { - "id": 10, - "completedAt": "2026-06-13T10:07:00.000Z" - }, - { - "id": 11, - "completedAt": "2026-06-13T10:15:00.000Z" - }, - { - "id": 12, - "completedAt": "2026-06-13T10:23:00.000Z" - }, - { - "id": 13, - "completedAt": "2026-06-13T10:31:00.000Z" - }, - { - "id": 14, - "completedAt": "2026-06-13T10:39:00.000Z" - }, - { - "id": 15, - "completedAt": "2026-06-13T10:47:00.000Z" - }, - { - "id": 16, - "completedAt": "2026-06-13T10:55:00.000Z" - } - ], - "26OSK0005": [ - { - "id": 1, - "completedAt": "2026-06-18T00:52:11.608Z" - }, - { - "id": 2 - }, - { - "id": 3 - }, - { - "id": 4 - }, - { - "id": 5 - }, - { - "id": 6 - }, - { - "id": 7 - }, - { - "id": 8 - }, - { - "id": 9 - }, - { - "id": 10 - }, - { - "id": 11 - }, - { - "id": 12 - }, - { - "id": 13 - }, - { - "id": 14 - }, - { - "id": 15 - }, - { - "id": 16 - } - ] -} diff --git a/server/data/prizeAwards.json b/server/data/prizeAwards.json deleted file mode 100644 index d00b107..0000000 --- a/server/data/prizeAwards.json +++ /dev/null @@ -1,35 +0,0 @@ -[ - { - "id": "26OSK0003-wheel-1", - "kidId": "26OSK0003", - "prizeId": "stickers", - "awardedAt": "2026-06-13T09:30:00.000Z", - "source": "wheel" - }, - { - "id": "26OSK0003-wheel-2", - "kidId": "26OSK0003", - "prizeId": "badges", - "awardedAt": "2026-06-13T10:05:00.000Z", - "source": "wheel" - }, - { - "id": "26OSK0003-wheel-3", - "kidId": "26OSK0003", - "prizeId": "notebook", - "awardedAt": "2026-06-13T10:42:00.000Z", - "source": "wheel" - }, - { - "awardedAt": "2026-06-18T00:55:04.140Z", - "id": "26OSK0001-wheel-3bf1604f-760b-4b1e-ba49-66196f2e99f1", - "kidId": "26OSK0001", - "prizeId": "notebook" - }, - { - "awardedAt": "2026-06-18T00:55:58.927Z", - "id": "26OSK0002-wheel-9e8006c4-e896-43f7-a660-1dcf68bd3d75", - "kidId": "26OSK0002", - "prizeId": "badges" - } -] diff --git a/server/data/prizes.json b/server/data/prizes.json deleted file mode 100644 index 8140c9b..0000000 --- a/server/data/prizes.json +++ /dev/null @@ -1,37 +0,0 @@ -[ - { - "id": "stickers", - "title": "Sticker pack", - "initialUnits": 18, - "given": 1, - "kind": "normal" - }, - { - "id": "badges", - "title": "OpenSouthCode badge", - "initialUnits": 12, - "given": 1, - "kind": "normal" - }, - { - "id": "notebook", - "title": "Robot notebook", - "initialUnits": 8, - "given": 1, - "kind": "normal" - }, - { - "id": "tshirt", - "title": "OpenSouthCode T-shirt", - "initialUnits": 100, - "given": 0, - "kind": "final" - }, - { - "id": "plushie", - "title": "Robot plushie", - "initialUnits": 2, - "given": 0, - "kind": "valuable" - } -] diff --git a/server/db-store.ts b/server/db-store.ts index 56f8b56..08b820b 100644 --- a/server/db-store.ts +++ b/server/db-store.ts @@ -1,7 +1,6 @@ import { neon } from '@netlify/neon'; import type { NeonQueryFunctionInTransaction, - NeonQueryInTransaction, } from '@neondatabase/serverless'; import type { MagicLinkTokenRecord, @@ -17,7 +16,6 @@ import { type RegisterKidCommand, type SavePrizeCommand, type StoreAdapter, - type StoreFile, type WritableStoreData, } from './store.js'; import type { @@ -235,108 +233,6 @@ function prizeQueries(tx: TransactionSql, prizes: Prize[]) { ]; } -async function writeStoreFiles( - sql: SqlClient, - snapshot: StoreData, - changedFiles: readonly StoreFile[], -) { - await sql.transaction((tx) => { - const queries: NeonQueryInTransaction[] = []; - - if (changedFiles.includes('conference')) { - queries.push(tx` - INSERT INTO conference_settings (id, kid_id_prefix, short_name, title) - VALUES ( - 'default', - ${snapshot.conference.kidIdPrefix}, - ${snapshot.conference.shortName}, - ${snapshot.conference.title} - ) - ON CONFLICT (id) DO UPDATE - SET kid_id_prefix = EXCLUDED.kid_id_prefix, - short_name = EXCLUDED.short_name, - title = EXCLUDED.title - `); - } - - if (changedFiles.includes('kids')) { - queries.push(tx`DELETE FROM kids`); - queries.push( - ...snapshot.kids.map( - (kid) => tx` - INSERT INTO kids (id, name, age, gender, language) - VALUES (${kid.id}, ${kid.name}, ${kid.age}, ${kid.gender}, ${kid.language}) - ON CONFLICT (id) DO UPDATE - SET name = EXCLUDED.name, - age = EXCLUDED.age, - gender = EXCLUDED.gender, - language = EXCLUDED.language - `, - ), - ); - } - - if (changedFiles.includes('prizeAwards')) { - queries.push(...prizeAwardQueries(tx, snapshot.prizeAwards)); - } - - if (changedFiles.includes('prizes')) { - queries.push(...prizeQueries(tx, snapshot.prizes)); - } - - if (changedFiles.includes('passportActivitiesByKid')) { - queries.push(tx`DELETE FROM passport_activities`); - for (const [kidId, passport] of Object.entries(snapshot.passportActivitiesByKid)) { - queries.push( - ...passport.map( - (activity) => tx` - INSERT INTO passport_activities (kid_id, activity_id, completed_at) - VALUES ( - ${kidId}, - ${activity.id}, - ${activity.completedAt ?? null}::timestamptz - ) - `, - ), - ); - } - } - - return queries; - }); -} - -async function replacePassport( - sql: SqlClient, - kidId: string, - passport: PassportActivity[], -) { - await sql.transaction((tx) => passportQueries(tx, kidId, passport)); -} - -async function replacePrizeAwardsForKid( - sql: SqlClient, - kidId: string, - prizeAwards: PrizeAward[], -) { - await sql.transaction((tx) => [ - tx`DELETE FROM prize_awards WHERE kid_id = ${kidId}`, - ...prizeAwards.map( - (award) => tx` - INSERT INTO prize_awards (id, kid_id, prize_id, source, awarded_at) - VALUES ( - ${award.id}, - ${award.kidId}, - ${award.prizeId}, - ${award.source ?? null}, - ${award.awardedAt}::timestamptz - ) - ON CONFLICT DO NOTHING - `, - ), - ]); -} - export function createDbStore(sql: SqlClient = neon()): StoreAdapter { async function readSnapshot(): Promise { const [conferenceRows, kidRows, passportRows, prizeRows, prizeAwardRows] = @@ -613,52 +509,44 @@ export function createDbStore(sql: SqlClient = neon()): StoreAdapter { async function restoreWritableData(data: WritableStoreData) { const snapshot = await readSnapshot(); - await writeStoreFiles( - sql, - { - ...snapshot, - passportActivitiesByKid: data.passportActivitiesByKid, - prizeAwards: data.prizeAwards, - prizes: syncPrizeGivenCache(data.prizes, data.prizeAwards), - }, - ['passportActivitiesByKid', 'prizeAwards', 'prizes'], - ); - - return readSnapshot(); + return resetData({ + ...snapshot, + passportActivitiesByKid: data.passportActivitiesByKid, + prizeAwards: data.prizeAwards, + prizes: syncPrizeGivenCache(data.prizes, data.prizeAwards), + }); } - async function updatePassportForKid( - kidId: string, - mutator: (snapshot: StoreData) => T | Promise, - ) { - const snapshot = await readSnapshot(); - const result = await mutator(snapshot); - await replacePassport(sql, kidId, snapshot.passportActivitiesByKid[kidId] ?? []); - return result; - } - - async function updatePrizeAwardsForKid( - kidId: string, - mutator: (snapshot: StoreData) => T | Promise, - ) { - const snapshot = await readSnapshot(); - const result = await mutator(snapshot); - await replacePrizeAwardsForKid( - sql, - kidId, - snapshot.prizeAwards.filter((award) => award.kidId === kidId), - ); - return result; - } + async function resetData(data: StoreData) { + await sql.transaction((tx) => [ + tx`DELETE FROM prize_awards`, + tx`DELETE FROM passport_activities`, + tx`DELETE FROM prizes`, + tx`DELETE FROM kids`, + tx`DELETE FROM conference_settings`, + tx` + INSERT INTO conference_settings (id, kid_id_prefix, short_name, title) + VALUES ( + 'default', + ${data.conference.kidIdPrefix}, + ${data.conference.shortName}, + ${data.conference.title} + ) + `, + ...data.kids.map( + (kid) => tx` + INSERT INTO kids (id, name, age, gender, language) + VALUES (${kid.id}, ${kid.name}, ${kid.age}, ${kid.gender}, ${kid.language}) + `, + ), + ...Object.entries(data.passportActivitiesByKid).flatMap(([kidId, passport]) => + passportQueries(tx, kidId, passport), + ), + ...prizeQueries(tx, syncPrizeGivenCache(data.prizes, data.prizeAwards)).slice(1), + ...prizeAwardQueries(tx, data.prizeAwards).slice(1), + ]); - async function updateSnapshot( - mutator: (snapshot: StoreData) => T | Promise, - changedFiles: readonly StoreFile[], - ) { - const snapshot = await readSnapshot(); - const result = await mutator(snapshot); - await writeStoreFiles(sql, snapshot, changedFiles); - return result; + return readSnapshot(); } return { @@ -666,16 +554,33 @@ export function createDbStore(sql: SqlClient = neon()): StoreAdapter { completePassportActivity, readSnapshot, registerKid, + resetData, restoreWritableData, savePrize, - updatePassportForKid, - updatePrizeAwardsForKid, - updateSnapshot, }; } export function createDbMagicTokenStore(sql: SqlClient = neon()) { return { + async appendToken(token: MagicLinkTokenRecord) { + await sql` + INSERT INTO magic_link_tokens ( + token_hash, + role, + activity_id, + created_at, + expires_at + ) + VALUES ( + ${token.tokenHash}, + ${token.role}, + ${token.activityId ?? null}, + ${token.createdAt}::timestamptz, + ${token.expiresAt}::timestamptz + ) + ON CONFLICT DO NOTHING + `; + }, async readTokens() { const rows = (await sql` SELECT diff --git a/server/dual-store.ts b/server/dual-store.ts deleted file mode 100644 index c629709..0000000 --- a/server/dual-store.ts +++ /dev/null @@ -1,225 +0,0 @@ -import type { MagicLinkTokenRecord, MagicLinkTokenStore } from './access-tokens.js'; -import type { - AwardPrizeCommand, - CompletePassportActivityCommand, - PrizeMutationResult, - RegisterKidCommand, - SavePrizeCommand, - StoreAdapter, - StoreFile, - WritableStoreData, -} from './store.js'; -import type { Kid, PassportActivity, PrizeAward, StoreData } from './types.js'; - -type DualStoreOptions = { - primary: StoreAdapter; - secondary: StoreAdapter; - strict: boolean; -}; - -function checkMatchingJson( - strict: boolean, - label: string, - primary: unknown, - secondary: unknown, -) { - if (JSON.stringify(primary) !== JSON.stringify(secondary)) { - const message = `Dual store ${label} mismatch between primary and secondary`; - - if (strict) { - throw new Error(message); - } - - console.error(message); - } -} - -async function writeSecondary( - strict: boolean, - label: string, - write: () => Promise, -) { - try { - return await write(); - } catch (error) { - if (strict) { - throw error; - } - - console.error(`Dual store secondary ${label} failed`, error); - return undefined; - } -} - -export function createDualStore({ - primary, - secondary, - strict, -}: DualStoreOptions): StoreAdapter { - async function mirrorSnapshotToSecondary( - primarySnapshot: StoreData, - changedFiles: readonly StoreFile[], - ) { - await writeSecondary(strict, 'snapshot write', () => - secondary.updateSnapshot((secondarySnapshot) => { - for (const changedFile of changedFiles) { - switch (changedFile) { - case 'conference': - secondarySnapshot.conference = primarySnapshot.conference; - break; - case 'kids': - secondarySnapshot.kids = primarySnapshot.kids; - break; - case 'passportActivitiesByKid': - secondarySnapshot.passportActivitiesByKid = - primarySnapshot.passportActivitiesByKid; - break; - case 'prizeAwards': - secondarySnapshot.prizeAwards = primarySnapshot.prizeAwards; - break; - case 'prizes': - secondarySnapshot.prizes = primarySnapshot.prizes; - break; - } - } - }, changedFiles), - ); - } - - return { - async awardPrize(command: AwardPrizeCommand): Promise { - const primaryResult = await primary.awardPrize(command); - const secondaryResult = await writeSecondary(strict, 'awardPrize', () => - secondary.awardPrize(command), - ); - - if (secondaryResult) { - checkMatchingJson(strict, 'awardPrize result', primaryResult, secondaryResult); - } - - return primaryResult; - }, - async completePassportActivity( - command: CompletePassportActivityCommand, - ): Promise { - const primaryResult = await primary.completePassportActivity(command); - const secondaryResult = await writeSecondary( - strict, - 'completePassportActivity', - () => secondary.completePassportActivity(command), - ); - - if (secondaryResult) { - checkMatchingJson( - strict, - 'completePassportActivity result', - primaryResult, - secondaryResult, - ); - } - - return primaryResult; - }, - readSnapshot() { - return primary.readSnapshot(); - }, - async registerKid(command: RegisterKidCommand): Promise { - const primaryResult = await primary.registerKid(command); - const secondaryResult = await writeSecondary(strict, 'registerKid', () => - secondary.registerKid(command), - ); - - if (secondaryResult) { - checkMatchingJson(strict, 'registerKid result', primaryResult, secondaryResult); - } - - return primaryResult; - }, - async restoreWritableData(data: WritableStoreData): Promise { - const primaryResult = await primary.restoreWritableData(data); - const secondaryResult = await writeSecondary(strict, 'restoreWritableData', () => - secondary.restoreWritableData(data), - ); - - if (secondaryResult) { - checkMatchingJson( - strict, - 'restoreWritableData result', - primaryResult, - secondaryResult, - ); - } - - return primaryResult; - }, - async savePrize(command: SavePrizeCommand): Promise { - const primaryResult = await primary.savePrize(command); - const secondaryResult = await writeSecondary(strict, 'savePrize', () => - secondary.savePrize(command), - ); - - if (secondaryResult) { - checkMatchingJson(strict, 'savePrize result', primaryResult, secondaryResult); - } - - return primaryResult; - }, - async updatePassportForKid( - kidId: string, - mutator: (snapshot: StoreData) => T | Promise, - ) { - const primaryResult = await primary.updatePassportForKid(kidId, mutator); - const primarySnapshot = await primary.readSnapshot(); - await writeSecondary(strict, 'updatePassportForKid', () => - secondary.updatePassportForKid(kidId, (secondarySnapshot) => { - secondarySnapshot.passportActivitiesByKid[kidId] = - primarySnapshot.passportActivitiesByKid[kidId] ?? []; - }), - ); - return primaryResult; - }, - async updatePrizeAwardsForKid( - kidId: string, - mutator: (snapshot: StoreData) => T | Promise, - ) { - const primaryResult = await primary.updatePrizeAwardsForKid(kidId, mutator); - const primarySnapshot = await primary.readSnapshot(); - await writeSecondary(strict, 'updatePrizeAwardsForKid', () => - secondary.updatePrizeAwardsForKid(kidId, (secondarySnapshot) => { - secondarySnapshot.prizeAwards = primarySnapshot.prizeAwards; - }), - ); - return primaryResult; - }, - async updateSnapshot( - mutator: (snapshot: StoreData) => T | Promise, - changedFiles: readonly StoreFile[], - ) { - const primaryResult = await primary.updateSnapshot(mutator, changedFiles); - await mirrorSnapshotToSecondary(await primary.readSnapshot(), changedFiles); - return primaryResult; - }, - }; -} - -export function createDualMagicTokenStore({ - primary, - secondary, - strict, -}: { - primary: MagicLinkTokenStore; - secondary: MagicLinkTokenStore; - strict: boolean; -}): MagicLinkTokenStore { - return { - readTokens() { - return primary.readTokens(); - }, - async writeTokens(tokens: MagicLinkTokenRecord[]) { - await primary.writeTokens(tokens); - await writeSecondary(strict, 'magic token write', () => - secondary.writeTokens(tokens), - ); - }, - }; -} diff --git a/server/index.ts b/server/index.ts index 2559325..85e5b7e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -2,13 +2,19 @@ import { createServer, type IncomingMessage } from 'node:http'; import { readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { setMagicTokenStore } from './access-tokens.js'; import { handleApiRequest, isApiPath } from './api.js'; +import { createDbMagicTokenStore, createDbStore } from './db-store.js'; +import { setStoreAdapter } from './store.js'; const port = Number(process.env.PORT ?? 3000); const distDir = path.resolve(process.env.KID_A_DIST_DIR ?? 'dist'); const currentFilePath = fileURLToPath(import.meta.url); const serverRoot = path.dirname(currentFilePath); +setStoreAdapter(createDbStore()); +setMagicTokenStore(createDbMagicTokenStore()); + const mimeTypes: Record = { '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', diff --git a/server/reset-stores.ts b/server/reset-db.ts similarity index 67% rename from server/reset-stores.ts rename to server/reset-db.ts index 819a444..df851c8 100644 --- a/server/reset-stores.ts +++ b/server/reset-db.ts @@ -1,14 +1,8 @@ import { readdir, readFile } from 'node:fs/promises'; import path from 'node:path'; import { neon } from '@netlify/neon'; -import { createBlobMagicTokenStore } from './access-tokens.js'; -import { createBlobStore } from './blob-store.js'; import { createDbMagicTokenStore, createDbStore } from './db-store.js'; -import { - getStoreFileName, - syncPrizeGivenCache, - type StoreAdapter, -} from './store.js'; +import { getStoreFileName, syncPrizeGivenCache } from './store.js'; import type { ConferenceData, Kid, @@ -69,34 +63,15 @@ async function applySchema(sql: SqlClient) { } } -async function resetStore(name: string, store: StoreAdapter) { - const seedSnapshot = await readSeedSnapshot(); - - await store.updateSnapshot( - (snapshot) => { - snapshot.conference = seedSnapshot.conference; - snapshot.kids = seedSnapshot.kids; - snapshot.passportActivitiesByKid = seedSnapshot.passportActivitiesByKid; - snapshot.prizeAwards = seedSnapshot.prizeAwards; - snapshot.prizes = seedSnapshot.prizes; - }, - ['conference', 'kids', 'passportActivitiesByKid', 'prizeAwards', 'prizes'], - ); - - console.log(`Reset ${name} store from ${seedDataDir}`); -} - async function main() { const sql = neon(); + const seedSnapshot = await readSeedSnapshot(); await applySchema(sql); - await resetStore('blob', createBlobStore()); - await resetStore('db', createDbStore(sql)); - await Promise.all([ - createBlobMagicTokenStore().writeTokens([]), - createDbMagicTokenStore(sql).writeTokens([]), - ]); - console.log('Cleared blob and DB magic-link tokens'); + await createDbStore(sql).resetData(seedSnapshot); + await createDbMagicTokenStore(sql).writeTokens([]); + console.log(`Reset DB store from ${seedDataDir}`); + console.log('Cleared DB magic-link tokens'); } main().catch((error: unknown) => { diff --git a/server/store.ts b/server/store.ts index 3dc4176..5cea215 100644 --- a/server/store.ts +++ b/server/store.ts @@ -1,14 +1,4 @@ -import { access, copyFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import type { - ConferenceData, - Kid, - PassportActivitiesByKid, - PassportActivity, - Prize, - PrizeAward, - StoreData, -} from './types.js'; +import type { Kid, PassportActivity, Prize, PrizeAward, StoreData } from './types.js'; export type StoreFile = keyof typeof storeFiles; @@ -66,20 +56,9 @@ export type StoreAdapter = { ): Promise; readSnapshot(): Promise; registerKid(command: RegisterKidCommand): Promise; + resetData(data: StoreData): Promise; restoreWritableData(data: WritableStoreData): Promise; savePrize(command: SavePrizeCommand): Promise; - updatePassportForKid( - kidId: string, - mutator: (snapshot: StoreData) => T | Promise, - ): Promise; - updatePrizeAwardsForKid( - kidId: string, - mutator: (snapshot: StoreData) => T | Promise, - ): Promise; - updateSnapshot( - mutator: (snapshot: StoreData) => T | Promise, - changedFiles: readonly StoreFile[], - ): Promise; }; const storeFiles = { @@ -90,8 +69,6 @@ const storeFiles = { prizes: 'prizes.json', } as const; -class StaleKidIdReadError extends Error {} - export class KidIdAllocationError extends Error { constructor() { super('Unable to allocate a fresh kid id'); @@ -110,74 +87,13 @@ export class PrizeOutOfStockError extends Error { } } -const kidRegistrationRetryDelayMs = 250; -const maxKidRegistrationAttempts = 20; - -const defaultDataDir = path.resolve(process.env.KID_A_DATA_DIR ?? 'server/data'); -const seedDataDir = path.resolve(process.env.KID_A_SEED_DATA_DIR ?? 'src/data'); - -async function pathExists(filePath: string) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} +let activeStore: StoreAdapter | undefined; export function getStoreFileName(storeFile: StoreFile) { return storeFiles[storeFile]; } -function delay(ms: number) { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -function getNextKidId(existingKids: Kid[], kidIdPrefix: string) { - const existingIds = new Set(existingKids.map((kid) => kid.id.toLowerCase())); - let sequence = existingKids.length + 1; - let nextId = `${kidIdPrefix}${sequence.toString().padStart(4, '0')}`; - - while (existingIds.has(nextId.toLowerCase())) { - sequence += 1; - nextId = `${kidIdPrefix}${sequence.toString().padStart(4, '0')}`; - } - - return nextId; -} - -function passportTemplate( - passportActivitiesByKid: PassportActivitiesByKid, -): PassportActivity[] { - return ( - Object.values(passportActivitiesByKid)[0]?.map((activity) => ({ - id: activity.id, - })) ?? [] - ); -} - -function ensurePassportForKid( - passportActivitiesByKid: PassportActivitiesByKid, - kidId: string, - activityId: number, -) { - const existingPassport = passportActivitiesByKid[kidId]; - - if (existingPassport) { - return existingPassport; - } - - const template = passportTemplate(passportActivitiesByKid); - const passport = - template.length > 0 ? template : ([{ id: activityId }] satisfies PassportActivity[]); - - passportActivitiesByKid[kidId] = passport; - return passport; -} - -function getPrizeGiven(prizeAwards: PrizeAward[], prizeId: string) { +export function getPrizeGiven(prizeAwards: PrizeAward[], prizeId: string) { return prizeAwards.filter((award) => award.prizeId === prizeId).length; } @@ -188,404 +104,40 @@ export function syncPrizeGivenCache(prizes: Prize[], prizeAwards: PrizeAward[]) })); } -function getPrizeRemaining(prize: Prize) { - return Math.max(prize.initialUnits - prize.given, 0); -} - -function createPrizeId(prizes: Prize[]) { - let suffix = prizes.length + 1; - let candidate = `prize-${suffix}`; - - while (prizes.some((prize) => prize.id === candidate)) { - suffix += 1; - candidate = `prize-${suffix}`; - } - - return candidate; -} - -function snapshotPrizeResponse(snapshot: StoreData, prize?: Prize): PrizeMutationResult { - return { - prize, - prizeAwards: snapshot.prizeAwards, - prizes: syncPrizeGivenCache(snapshot.prizes, snapshot.prizeAwards), - }; -} - -function applyRegisterKid(snapshot: StoreData, command: RegisterKidCommand) { - const kidId = getNextKidId(snapshot.kids, snapshot.conference.kidIdPrefix); - - if (kidId.toLowerCase() === command.lastKnownKidId) { - throw new StaleKidIdReadError(); - } - - const kid: Kid = { - age: command.age, - gender: command.gender, - id: kidId, - language: command.language, - name: command.name, - }; - const passport = passportTemplate(snapshot.passportActivitiesByKid); - - snapshot.kids.push(kid); - snapshot.passportActivitiesByKid[kid.id] = passport; - - return kid; -} - -function applyCompletePassportActivity( - snapshot: StoreData, - command: CompletePassportActivityCommand, -) { - const passport = ensurePassportForKid( - snapshot.passportActivitiesByKid, - command.kidId, - command.activityId, - ); - const matchingActivity = passport.find( - (activity) => activity.id === command.activityId, - ); - - if (matchingActivity) { - matchingActivity.completedAt ??= command.completedAt; - } else { - passport.push({ completedAt: command.completedAt, id: command.activityId }); - passport.sort((left, right) => left.id - right.id); - } - - return passport; -} - -function applySavePrize(snapshot: StoreData, command: SavePrizeCommand) { - if (command.type === 'create') { - const prize: Prize = { - given: 0, - id: createPrizeId(snapshot.prizes), - initialUnits: command.initialUnits, - kind: 'normal', - title: command.title, - }; - - snapshot.prizes.push(prize); - return snapshotPrizeResponse(snapshot, prize); - } - - const syncedPrizes = syncPrizeGivenCache(snapshot.prizes, snapshot.prizeAwards); - const prize = syncedPrizes.find((entry) => entry.id === command.prizeId); - - if (!prize) { - throw new UnknownPrizeError(command.prizeId); - } - - const initialUnits = - command.initialUnits === undefined - ? prize.initialUnits - : Math.max(command.initialUnits, getPrizeGiven(snapshot.prizeAwards, prize.id)); - - snapshot.prizes = snapshot.prizes.map((entry) => - entry.id === prize.id - ? { - ...entry, - given: getPrizeGiven(snapshot.prizeAwards, prize.id), - initialUnits, - kind: command.prizeKind ?? prize.kind, - title: command.title ?? prize.title, - } - : entry, - ); - - return snapshotPrizeResponse( - snapshot, - snapshot.prizes.find((entry) => entry.id === prize.id), - ); -} - -function applyAwardPrize(snapshot: StoreData, command: AwardPrizeCommand) { - const syncedPrizes = syncPrizeGivenCache(snapshot.prizes, snapshot.prizeAwards); - const prize = syncedPrizes.find((entry) => entry.id === command.prizeId); - - if (!prize) { - throw new UnknownPrizeError(command.prizeId); - } - - if (command.source === 'passportCompletion') { - const existingAward = snapshot.prizeAwards.find( - (award) => award.kidId === command.kidId && award.source === 'passportCompletion', - ); - - if (existingAward) { - return snapshot.prizeAwards.filter((award) => award.kidId === command.kidId); - } - } - - if (getPrizeRemaining(prize) <= 0) { - throw new PrizeOutOfStockError(command.prizeId); - } - - snapshot.prizeAwards.push({ - awardedAt: command.awardedAt, - id: command.awardId, - kidId: command.kidId, - prizeId: prize.id, - ...(command.source ? { source: command.source } : {}), - }); - - return snapshot.prizeAwards.filter((award) => award.kidId === command.kidId); -} - -type SnapshotUpdater = ( - mutator: (snapshot: StoreData) => T | Promise, - changedFiles: readonly StoreFile[], -) => Promise; - -type KidScopedUpdater = ( - kidId: string, - mutator: (snapshot: StoreData) => T | Promise, -) => Promise; - -export async function runRegisterKidCommand( - command: RegisterKidCommand, - updateSnapshot: SnapshotUpdater, -) { - for (let attempt = 1; attempt <= maxKidRegistrationAttempts; attempt += 1) { - try { - return await updateSnapshot( - (snapshot) => applyRegisterKid(snapshot, command), - ['kids', 'passportActivitiesByKid'], - ); - } catch (error) { - if ( - error instanceof StaleKidIdReadError && - attempt < maxKidRegistrationAttempts - ) { - await delay(kidRegistrationRetryDelayMs); - continue; - } - - if (error instanceof StaleKidIdReadError) { - throw new KidIdAllocationError(); - } - - throw error; - } - } - - throw new KidIdAllocationError(); -} - -export function runCompletePassportActivityCommand( - command: CompletePassportActivityCommand, - updatePassportForKid: KidScopedUpdater, -) { - return updatePassportForKid(command.kidId, (snapshot) => - applyCompletePassportActivity(snapshot, command), - ); -} - -export function runSavePrizeCommand( - command: SavePrizeCommand, - updateSnapshot: SnapshotUpdater, -) { - return updateSnapshot((snapshot) => applySavePrize(snapshot, command), ['prizes']); -} - -export function runAwardPrizeCommand( - command: AwardPrizeCommand, - updatePrizeAwardsForKid: KidScopedUpdater, -) { - return updatePrizeAwardsForKid(command.kidId, (snapshot) => - applyAwardPrize(snapshot, command), - ); -} - -export function runRestoreWritableDataCommand( - data: WritableStoreData, - updateSnapshot: SnapshotUpdater, -) { - return updateSnapshot( - (snapshot) => { - snapshot.passportActivitiesByKid = data.passportActivitiesByKid; - snapshot.prizeAwards = data.prizeAwards; - snapshot.prizes = syncPrizeGivenCache(data.prizes, data.prizeAwards); - - return snapshot; - }, - ['passportActivitiesByKid', 'prizeAwards', 'prizes'], - ); +export function setStoreAdapter(store: StoreAdapter) { + activeStore = store; } -export function createFileStore(): StoreAdapter { - let seedPromise: Promise | undefined; - let writeQueue: Promise = Promise.resolve(); - - function getStorePath(storeFile: StoreFile) { - return path.join(defaultDataDir, storeFiles[storeFile]); +function requireStore() { + if (!activeStore) { + throw new Error('Store adapter has not been configured'); } - async function ensureDataFiles() { - seedPromise ??= (async () => { - await mkdir(defaultDataDir, { recursive: true }); - - await Promise.all( - Object.entries(storeFiles).map(async ([storeFile, fileName]) => { - const targetPath = getStorePath(storeFile as StoreFile); - - if (await pathExists(targetPath)) { - return; - } - - await copyFile(path.join(seedDataDir, fileName), targetPath); - }), - ); - })(); - - return seedPromise; - } - - async function readJson(storeFile: StoreFile): Promise { - return JSON.parse(await readFile(getStorePath(storeFile), 'utf8')) as T; - } - - async function writeJson(storeFile: StoreFile, value: unknown) { - const targetPath = getStorePath(storeFile); - const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`; - - await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`); - await rename(tempPath, targetPath); - } - - async function readSnapshotUnlocked(): Promise { - await ensureDataFiles(); - - const [conference, kids, passportActivitiesByKid, prizeAwards, prizes] = - await Promise.all([ - readJson('conference'), - readJson('kids'), - readJson('passportActivitiesByKid'), - readJson('prizeAwards'), - readJson('prizes'), - ]); - - return { - conference, - kids, - passportActivitiesByKid, - prizeAwards, - prizes, - }; - } - - async function readSnapshot() { - await writeQueue.catch(() => undefined); - return readSnapshotUnlocked(); - } - - async function updateSnapshot( - mutator: (snapshot: StoreData) => T | Promise, - changedFiles: readonly StoreFile[], - ) { - const previousWrite = writeQueue; - const nextWrite = previousWrite - .catch(() => undefined) - .then(async () => { - const snapshot = await readSnapshotUnlocked(); - const result = await mutator(snapshot); - - await Promise.all( - changedFiles.map((storeFile) => writeJson(storeFile, snapshot[storeFile])), - ); - - return result; - }); - - writeQueue = nextWrite.then( - () => undefined, - () => undefined, - ); - - return nextWrite; - } - - async function updatePassportForKid( - kidId: string, - mutator: (snapshot: StoreData) => T | Promise, - ) { - void kidId; - return updateSnapshot(mutator, ['passportActivitiesByKid']); - } - - async function updatePrizeAwardsForKid( - kidId: string, - mutator: (snapshot: StoreData) => T | Promise, - ) { - void kidId; - return updateSnapshot(mutator, ['prizeAwards']); - } - - return { - awardPrize: (command) => runAwardPrizeCommand(command, updatePrizeAwardsForKid), - completePassportActivity: (command) => - runCompletePassportActivityCommand(command, updatePassportForKid), - readSnapshot, - registerKid: (command) => runRegisterKidCommand(command, updateSnapshot), - restoreWritableData: (data) => runRestoreWritableDataCommand(data, updateSnapshot), - savePrize: (command) => runSavePrizeCommand(command, updateSnapshot), - updatePassportForKid, - updatePrizeAwardsForKid, - updateSnapshot, - }; -} - -let activeStore = createFileStore(); - -export function setStoreAdapter(store: StoreAdapter) { - activeStore = store; + return activeStore; } export async function readSnapshot() { - return activeStore.readSnapshot(); + return requireStore().readSnapshot(); } export async function registerKid(command: RegisterKidCommand) { - return activeStore.registerKid(command); + return requireStore().registerKid(command); } export async function completePassportActivity( command: CompletePassportActivityCommand, ) { - return activeStore.completePassportActivity(command); + return requireStore().completePassportActivity(command); } export async function savePrize(command: SavePrizeCommand) { - return activeStore.savePrize(command); + return requireStore().savePrize(command); } export async function awardPrize(command: AwardPrizeCommand) { - return activeStore.awardPrize(command); + return requireStore().awardPrize(command); } export async function restoreWritableData(data: WritableStoreData) { - return activeStore.restoreWritableData(data); -} - -export async function updateSnapshot( - mutator: (snapshot: StoreData) => T | Promise, - changedFiles: readonly StoreFile[], -) { - return activeStore.updateSnapshot(mutator, changedFiles); -} - -export async function updatePassportForKid( - kidId: string, - mutator: (snapshot: StoreData) => T | Promise, -) { - return activeStore.updatePassportForKid(kidId, mutator); -} - -export async function updatePrizeAwardsForKid( - kidId: string, - mutator: (snapshot: StoreData) => T | Promise, -) { - return activeStore.updatePrizeAwardsForKid(kidId, mutator); + return requireStore().restoreWritableData(data); } From fbdeeb72d7a868d0e8571e521150ee1974ca3919 Mon Sep 17 00:00:00 2001 From: pablonete Date: Wed, 24 Jun 2026 23:37:17 +0200 Subject: [PATCH 2/9] Reset DB during Netlify build Temporarily run the DB reset script before the Netlify build so the next deploy initializes the schema and seed data for the DB-only storage rollout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- netlify.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netlify.toml b/netlify.toml index 0c3a307..582f088 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,5 +1,5 @@ [build] - command = "npm run build:netlify" + command = "npm run data:reset-db && npm run build:netlify" publish = "dist" [functions] From 4c98d2a5888ee87cba6b154b11856956050889d0 Mon Sep 17 00:00:00 2001 From: pablonete Date: Wed, 24 Jun 2026 23:40:25 +0200 Subject: [PATCH 3/9] Initialize DB schema at runtime Apply DB migrations and seed an empty database during server startup instead of running destructive reset commands during Netlify builds. Keep the manual reset command for explicit non-production resets. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 5 ++- docs/storage-json.md | 9 ++-- netlify.toml | 3 +- netlify/functions/api.ts | 2 + server/db-bootstrap.ts | 97 ++++++++++++++++++++++++++++++++++++++++ server/index.ts | 2 + server/reset-db.ts | 90 +++++-------------------------------- 7 files changed, 121 insertions(+), 87 deletions(-) create mode 100644 server/db-bootstrap.ts diff --git a/README.md b/README.md index 0301672..f4b59b9 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,9 @@ Node endpoints instead of bundled mutable sample data. The Node server serves `netlify.toml` also routes those endpoints to `netlify/functions/api.ts`. Netlify Functions and the local Node server use Netlify DB/Postgres for writable -state and staff magic-link token hashes. Apply the schema and reset -non-production data from committed seed JSON with: +state and staff magic-link token hashes. On startup, the server applies DB +migrations and seeds an empty DB from committed JSON in `src/data`. To force a +non-production reset later, run: ```bash NETLIFY_DATABASE_URL=... npm run data:reset-db diff --git a/docs/storage-json.md b/docs/storage-json.md index 206c4cd..249ccba 100644 --- a/docs/storage-json.md +++ b/docs/storage-json.md @@ -1,8 +1,8 @@ # Storage reference The app uses Netlify DB/Postgres as the only writable server-side storage -backend. Committed JSON files in `src/data` are demo seed data and can be used to -reset a non-production DB. +backend. On startup, the server applies DB migrations and seeds an empty DB from +committed JSON files in `src/data`. ## Runtime data @@ -17,9 +17,8 @@ reset a non-production DB. ## Resetting non-production data -The app is not in production yet, so testing data can be discarded. To apply the -DB schema, reset writable data from committed seed JSON, and clear magic-link -tokens, run: +The app is not in production yet, so testing data can be discarded. To force a +reset from committed seed JSON and clear magic-link tokens, run: ```sh NETLIFY_DATABASE_URL=... npm run data:reset-db diff --git a/netlify.toml b/netlify.toml index 582f088..fb64f3c 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,9 +1,10 @@ [build] - command = "npm run data:reset-db && npm run build:netlify" + command = "npm run build:netlify" publish = "dist" [functions] directory = "netlify/functions" + included_files = ["db/migrations/*.sql", "src/data/*.json"] [[redirects]] from = "/api/passport" diff --git a/netlify/functions/api.ts b/netlify/functions/api.ts index c8e8fe5..d378065 100644 --- a/netlify/functions/api.ts +++ b/netlify/functions/api.ts @@ -1,5 +1,6 @@ import { setMagicTokenStore } from '../../server/access-tokens.js'; import { handleApiRequest } from '../../server/api.js'; +import { ensureDbInitialized } from '../../server/db-bootstrap.js'; import { createDbMagicTokenStore, createDbStore } from '../../server/db-store.js'; import { setStoreAdapter } from '../../server/store.js'; @@ -17,6 +18,7 @@ function configureStores() { } export async function handler(event: NetlifyEvent) { + await ensureDbInitialized(); configureStores(); const response = await handleApiRequest({ body: event.body, diff --git a/server/db-bootstrap.ts b/server/db-bootstrap.ts new file mode 100644 index 0000000..4c1f1c7 --- /dev/null +++ b/server/db-bootstrap.ts @@ -0,0 +1,97 @@ +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { neon } from '@netlify/neon'; +import { createDbMagicTokenStore, createDbStore } from './db-store.js'; +import { getStoreFileName, syncPrizeGivenCache } from './store.js'; +import type { + ConferenceData, + Kid, + PassportActivitiesByKid, + Prize, + PrizeAward, + StoreData, +} from './types.js'; + +type SqlClient = ReturnType; + +const migrationsDir = path.resolve('db/migrations'); +const seedDataDir = path.resolve(process.env.KID_A_SEED_DATA_DIR ?? 'src/data'); +let initializationPromise: Promise | undefined; + +async function readSeedJson(fileName: string): Promise { + return JSON.parse(await readFile(path.join(seedDataDir, fileName), 'utf8')) as T; +} + +async function readSeedSnapshot(): Promise { + const [conference, kids, passportActivitiesByKid, prizeAwards, prizes] = + await Promise.all([ + readSeedJson(getStoreFileName('conference')), + readSeedJson(getStoreFileName('kids')), + readSeedJson( + getStoreFileName('passportActivitiesByKid'), + ), + readSeedJson(getStoreFileName('prizeAwards')), + readSeedJson(getStoreFileName('prizes')), + ]); + + return { + conference, + kids, + passportActivitiesByKid, + prizeAwards, + prizes: syncPrizeGivenCache(prizes, prizeAwards), + }; +} + +function splitSqlStatements(sqlText: string) { + return sqlText + .split(/;\s*(?:\r?\n|$)/) + .map((statement) => statement.trim()) + .filter(Boolean); +} + +export async function applyDbSchema(sql: SqlClient = neon()) { + const migrationFiles = (await readdir(migrationsDir)) + .filter((fileName) => fileName.endsWith('.sql')) + .sort(); + + for (const migrationFile of migrationFiles) { + const sqlText = await readFile(path.join(migrationsDir, migrationFile), 'utf8'); + + for (const statement of splitSqlStatements(sqlText)) { + await sql.query(statement); + } + } +} + +async function hasSeedData(sql: SqlClient) { + const rows = (await sql` + SELECT EXISTS ( + SELECT 1 + FROM conference_settings + WHERE id = 'default' + ) AS has_conference + `) as Array<{ has_conference: boolean }>; + + return rows[0]?.has_conference === true; +} + +export async function resetDb(sql: SqlClient = neon()) { + const seedSnapshot = await readSeedSnapshot(); + + await applyDbSchema(sql); + await createDbStore(sql).resetData(seedSnapshot); + await createDbMagicTokenStore(sql).writeTokens([]); +} + +export async function ensureDbInitialized(sql: SqlClient = neon()) { + initializationPromise ??= (async () => { + await applyDbSchema(sql); + + if (!(await hasSeedData(sql))) { + await resetDb(sql); + } + })(); + + return initializationPromise; +} diff --git a/server/index.ts b/server/index.ts index 85e5b7e..2fa611b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { setMagicTokenStore } from './access-tokens.js'; import { handleApiRequest, isApiPath } from './api.js'; +import { ensureDbInitialized } from './db-bootstrap.js'; import { createDbMagicTokenStore, createDbStore } from './db-store.js'; import { setStoreAdapter } from './store.js'; @@ -14,6 +15,7 @@ const serverRoot = path.dirname(currentFilePath); setStoreAdapter(createDbStore()); setMagicTokenStore(createDbMagicTokenStore()); +await ensureDbInitialized(); const mimeTypes: Record = { '.css': 'text/css; charset=utf-8', diff --git a/server/reset-db.ts b/server/reset-db.ts index df851c8..e533720 100644 --- a/server/reset-db.ts +++ b/server/reset-db.ts @@ -1,80 +1,12 @@ -import { readdir, readFile } from 'node:fs/promises'; -import path from 'node:path'; import { neon } from '@netlify/neon'; -import { createDbMagicTokenStore, createDbStore } from './db-store.js'; -import { getStoreFileName, syncPrizeGivenCache } from './store.js'; -import type { - ConferenceData, - Kid, - PassportActivitiesByKid, - Prize, - PrizeAward, - StoreData, -} from './types.js'; - -type SqlClient = ReturnType; - -const migrationsDir = path.resolve('db/migrations'); -const seedDataDir = path.resolve(process.env.KID_A_SEED_DATA_DIR ?? 'src/data'); - -async function readSeedJson(fileName: string): Promise { - return JSON.parse(await readFile(path.join(seedDataDir, fileName), 'utf8')) as T; -} - -async function readSeedSnapshot(): Promise { - const [conference, kids, passportActivitiesByKid, prizeAwards, prizes] = - await Promise.all([ - readSeedJson(getStoreFileName('conference')), - readSeedJson(getStoreFileName('kids')), - readSeedJson( - getStoreFileName('passportActivitiesByKid'), - ), - readSeedJson(getStoreFileName('prizeAwards')), - readSeedJson(getStoreFileName('prizes')), - ]); - - return { - conference, - kids, - passportActivitiesByKid, - prizeAwards, - prizes: syncPrizeGivenCache(prizes, prizeAwards), - }; -} - -function splitSqlStatements(sqlText: string) { - return sqlText - .split(/;\s*(?:\r?\n|$)/) - .map((statement) => statement.trim()) - .filter(Boolean); -} - -async function applySchema(sql: SqlClient) { - const migrationFiles = (await readdir(migrationsDir)) - .filter((fileName) => fileName.endsWith('.sql')) - .sort(); - - for (const migrationFile of migrationFiles) { - const sqlText = await readFile(path.join(migrationsDir, migrationFile), 'utf8'); - - for (const statement of splitSqlStatements(sqlText)) { - await sql.query(statement); - } - } -} - -async function main() { - const sql = neon(); - const seedSnapshot = await readSeedSnapshot(); - - await applySchema(sql); - await createDbStore(sql).resetData(seedSnapshot); - await createDbMagicTokenStore(sql).writeTokens([]); - console.log(`Reset DB store from ${seedDataDir}`); - console.log('Cleared DB magic-link tokens'); -} - -main().catch((error: unknown) => { - console.error(error); - process.exitCode = 1; -}); +import { resetDb } from './db-bootstrap.js'; + +resetDb(neon()) + .then(() => { + console.log('Reset DB store from seed data'); + console.log('Cleared DB magic-link tokens'); + }) + .catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }); From c590c2cab61633ef6a2db6c1feaab7c6aac9ecd6 Mon Sep 17 00:00:00 2001 From: pablonete Date: Wed, 24 Jun 2026 23:52:25 +0200 Subject: [PATCH 4/9] Use current Netlify Database package Switch DB connection setup from the legacy @netlify/neon wrapper to the current @netlify/database package while keeping Neon's query client for SQL execution. Update docs to use NETLIFY_DB_URL and ignore the local .netlify folder. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 3 + README.md | 5 +- docs/storage-json.md | 2 +- package-lock.json | 286 ++++++++++++++++++++++++++++++++++++++++- package.json | 3 +- server/db-bootstrap.ts | 16 ++- server/db-store.ts | 14 +- server/reset-db.ts | 4 +- 8 files changed, 309 insertions(+), 24 deletions(-) diff --git a/.gitignore b/.gitignore index e092e61..a7dd434 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ dist-ssr *.local .DS_Store *.tsbuildinfo + +# Local Netlify folder +.netlify diff --git a/README.md b/README.md index f4b59b9..3083634 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ runs linting, builds the app, uploads the Pages artifact, and deploys from ### Alternate Node/Netlify deployment The static GitHub Pages deployment remains unchanged. For a stateful local Node -deployment, set `NETLIFY_DATABASE_URL`, build both the frontend and server, then +deployment, run it through `netlify dev` or set `NETLIFY_DB_URL`, build both the +frontend and server, then run the compiled HTTP server: ```bash @@ -59,7 +60,7 @@ migrations and seeds an empty DB from committed JSON in `src/data`. To force a non-production reset later, run: ```bash -NETLIFY_DATABASE_URL=... npm run data:reset-db +NETLIFY_DB_URL=... npm run data:reset-db ``` Set `ADMIN_PASSWORD` to enable the `/admin` page to generate 1-day desk, wheel, diff --git a/docs/storage-json.md b/docs/storage-json.md index 249ccba..3d596ed 100644 --- a/docs/storage-json.md +++ b/docs/storage-json.md @@ -21,7 +21,7 @@ The app is not in production yet, so testing data can be discarded. To force a reset from committed seed JSON and clear magic-link tokens, run: ```sh -NETLIFY_DATABASE_URL=... npm run data:reset-db +NETLIFY_DB_URL=... npm run data:reset-db ``` ## Schema diff --git a/package-lock.json b/package-lock.json index d97ff2f..20bd8a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "name": "kid-a", "version": "0.0.0", "dependencies": { - "@netlify/neon": "^0.1.2", + "@neondatabase/serverless": "^1.1.0", + "@netlify/database": "^1.1.0", "@primer/octicons-react": "^19.28.1", "@types/qrcode": "^1.5.6", "jsqr": "^1.4.0", @@ -286,13 +287,29 @@ "node": ">=19.0.0" } }, - "node_modules/@netlify/neon": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@netlify/neon/-/neon-0.1.2.tgz", - "integrity": "sha512-F8+Dnu+nvGZruskblFwTopv/CUbHi2VOP4bJpDhRo85UFawZ/wMPmxnWQ0CLYwdAERU1OYoQyXOQM19HxAHjQQ==", - "license": "ISC", + "node_modules/@netlify/database": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@netlify/database/-/database-1.1.0.tgz", + "integrity": "sha512-Y36JMyQfxwHxvtFzF8K+2PyooGFQXzPNINw/guujQfMujfAwUTwv7f6XiSL3c5orNy8YZTqVVWVjkbFarhDRjw==", + "license": "MIT", "dependencies": { - "@neondatabase/serverless": "1.x" + "@neondatabase/serverless": "^1.1.0", + "@netlify/runtime-utils": "2.3.0", + "pg": "^8.13.0", + "waddler": "^0.1.1", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.6.1" + } + }, + "node_modules/@netlify/runtime-utils": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@netlify/runtime-utils/-/runtime-utils-2.3.0.tgz", + "integrity": "sha512-cW8weDvsKV7zfia2m5EcBy6KILGoPD+eYZ3qWNGnIo05DGF28goPES0xKSDkNYgAF/2rRSIhie2qcBhbGVgSRg==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || >=20" } }, "node_modules/@oxc-project/types": { @@ -1942,6 +1959,95 @@ "node": ">=8" } }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2000,6 +2106,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2209,6 +2354,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -2418,6 +2572,94 @@ } } }, + "node_modules/waddler": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/waddler/-/waddler-0.1.1.tgz", + "integrity": "sha512-lBJXYFBLEpYe+scAeCJmLj6Iqweuq1whM6Am3I9WfopOCFxvKz8Nq5hXoy8/b3zwJqHIQMglFIvM4skRydSpZg==", + "license": "MIT", + "peerDependencies": { + "@clickhouse/client": "^1.11.2", + "@duckdb/node-api": "^1.1.2-alpha.4", + "@electric-sql/pglite": "^0.2.17", + "@libsql/client": "^0.15.4", + "@libsql/client-wasm": "^0.15.4", + "@neondatabase/serverless": "^1.0.0", + "@planetscale/database": "^1.19.0", + "@tidbcloud/serverless": "^0.2.0", + "@vercel/postgres": "^0.10.0", + "@xata.io/client": "^0.30.1", + "better-sqlite3": "^11.9.1", + "bun-types": "*", + "duckdb": "^1.2.1", + "gel": "^2.0.2", + "mysql2": "^3.14.0", + "pg": "^8.14.0", + "pg-query-stream": "^4.8.0", + "postgres": "^3.4.5" + }, + "peerDependenciesMeta": { + "@clickhouse/client": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@duckdb/node-api": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "duckdb": { + "optional": true + }, + "gel": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-query-stream": { + "optional": true + }, + "postgres": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2464,6 +2706,36 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", diff --git a/package.json b/package.json index 8b8877b..388cfee 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "start:node": "node dist-server/server/index.js" }, "dependencies": { - "@netlify/neon": "^0.1.2", + "@neondatabase/serverless": "^1.1.0", + "@netlify/database": "^1.1.0", "@primer/octicons-react": "^19.28.1", "@types/qrcode": "^1.5.6", "jsqr": "^1.4.0", diff --git a/server/db-bootstrap.ts b/server/db-bootstrap.ts index 4c1f1c7..6b8dae2 100644 --- a/server/db-bootstrap.ts +++ b/server/db-bootstrap.ts @@ -1,7 +1,11 @@ import { readdir, readFile } from 'node:fs/promises'; import path from 'node:path'; -import { neon } from '@netlify/neon'; -import { createDbMagicTokenStore, createDbStore } from './db-store.js'; +import { + createDbMagicTokenStore, + createDbStore, + createSqlClient, + type SqlClient, +} from './db-store.js'; import { getStoreFileName, syncPrizeGivenCache } from './store.js'; import type { ConferenceData, @@ -12,8 +16,6 @@ import type { StoreData, } from './types.js'; -type SqlClient = ReturnType; - const migrationsDir = path.resolve('db/migrations'); const seedDataDir = path.resolve(process.env.KID_A_SEED_DATA_DIR ?? 'src/data'); let initializationPromise: Promise | undefined; @@ -50,7 +52,7 @@ function splitSqlStatements(sqlText: string) { .filter(Boolean); } -export async function applyDbSchema(sql: SqlClient = neon()) { +export async function applyDbSchema(sql: SqlClient = createSqlClient()) { const migrationFiles = (await readdir(migrationsDir)) .filter((fileName) => fileName.endsWith('.sql')) .sort(); @@ -76,7 +78,7 @@ async function hasSeedData(sql: SqlClient) { return rows[0]?.has_conference === true; } -export async function resetDb(sql: SqlClient = neon()) { +export async function resetDb(sql: SqlClient = createSqlClient()) { const seedSnapshot = await readSeedSnapshot(); await applyDbSchema(sql); @@ -84,7 +86,7 @@ export async function resetDb(sql: SqlClient = neon()) { await createDbMagicTokenStore(sql).writeTokens([]); } -export async function ensureDbInitialized(sql: SqlClient = neon()) { +export async function ensureDbInitialized(sql: SqlClient = createSqlClient()) { initializationPromise ??= (async () => { await applyDbSchema(sql); diff --git a/server/db-store.ts b/server/db-store.ts index 08b820b..3d5b186 100644 --- a/server/db-store.ts +++ b/server/db-store.ts @@ -1,7 +1,9 @@ -import { neon } from '@netlify/neon'; +import { getConnectionString } from '@netlify/database'; import type { + NeonQueryFunction, NeonQueryFunctionInTransaction, } from '@neondatabase/serverless'; +import { neon } from '@neondatabase/serverless'; import type { MagicLinkTokenRecord, MagicLinkTokenStore, @@ -28,7 +30,7 @@ import type { StoreData, } from './types.js'; -type SqlClient = ReturnType; +export type SqlClient = NeonQueryFunction; type TransactionSql = NeonQueryFunctionInTransaction; type Row = Record; @@ -36,6 +38,10 @@ const kidRegistrationRetryDelayMs = 250; const maxKidRegistrationAttempts = 20; const generatedIdLookahead = 1000; +export function createSqlClient() { + return neon(process.env.NETLIFY_DATABASE_URL ?? getConnectionString()); +} + function delay(ms: number) { return new Promise((resolve) => { setTimeout(resolve, ms); @@ -233,7 +239,7 @@ function prizeQueries(tx: TransactionSql, prizes: Prize[]) { ]; } -export function createDbStore(sql: SqlClient = neon()): StoreAdapter { +export function createDbStore(sql: SqlClient = createSqlClient()): StoreAdapter { async function readSnapshot(): Promise { const [conferenceRows, kidRows, passportRows, prizeRows, prizeAwardRows] = await Promise.all([ @@ -560,7 +566,7 @@ export function createDbStore(sql: SqlClient = neon()): StoreAdapter { }; } -export function createDbMagicTokenStore(sql: SqlClient = neon()) { +export function createDbMagicTokenStore(sql: SqlClient = createSqlClient()) { return { async appendToken(token: MagicLinkTokenRecord) { await sql` diff --git a/server/reset-db.ts b/server/reset-db.ts index e533720..977cb36 100644 --- a/server/reset-db.ts +++ b/server/reset-db.ts @@ -1,7 +1,7 @@ -import { neon } from '@netlify/neon'; import { resetDb } from './db-bootstrap.js'; +import { createSqlClient } from './db-store.js'; -resetDb(neon()) +resetDb(createSqlClient()) .then(() => { console.log('Reset DB store from seed data'); console.log('Cleared DB magic-link tokens'); From 29bfb20d045acb8e90b495f5f9d60837b55e6206 Mon Sep 17 00:00:00 2001 From: pablonete Date: Wed, 24 Jun 2026 23:58:23 +0200 Subject: [PATCH 5/9] Validate DB connection URL before Neon client Accept NETLIFY_DB_URL, NETLIFY_DATABASE_URL, or DATABASE_URL when they contain a Postgres URL, and fail clearly if Netlify Database has not provided a usable connection string. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- server/db-store.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/server/db-store.ts b/server/db-store.ts index 3d5b186..00f810d 100644 --- a/server/db-store.ts +++ b/server/db-store.ts @@ -38,8 +38,26 @@ const kidRegistrationRetryDelayMs = 250; const maxKidRegistrationAttempts = 20; const generatedIdLookahead = 1000; +function isPostgresUrl(value: string | undefined): value is string { + return value?.startsWith('postgres://') === true || + value?.startsWith('postgresql://') === true; +} + export function createSqlClient() { - return neon(process.env.NETLIFY_DATABASE_URL ?? getConnectionString()); + const connectionString = [ + process.env.NETLIFY_DB_URL, + process.env.NETLIFY_DATABASE_URL, + process.env.DATABASE_URL, + ].find(isPostgresUrl); + const netlifyConnectionString = connectionString ?? getConnectionString(); + + if (!isPostgresUrl(netlifyConnectionString)) { + throw new Error( + 'Netlify Database is not configured with a Postgres connection URL. Run `netlify database status` and ensure the database is enabled.', + ); + } + + return neon(netlifyConnectionString); } function delay(ms: number) { From 46321224ff3f1a50b11c507ee7bba0dc2cb6507f Mon Sep 17 00:00:00 2001 From: pablonete Date: Thu, 25 Jun 2026 00:06:30 +0200 Subject: [PATCH 6/9] Use Netlify Dev for local stateful runtime Remove the custom Node server path and rely on Netlify Dev for local function and database runtime parity. Switch DB access to the current Netlify Database package with a pool-backed SQL wrapper so local Postgres URLs and deployed database URLs both work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 26 +++++----- eslint.config.js | 2 +- package-lock.json | 1 - package.json | 6 +-- server/db-store.ts | 96 ++++++++++++++++++++++++++++++------- server/index.ts | 115 --------------------------------------------- 6 files changed, 94 insertions(+), 152 deletions(-) delete mode 100644 server/index.ts diff --git a/README.md b/README.md index 3083634..6364c42 100644 --- a/README.md +++ b/README.md @@ -37,27 +37,23 @@ runs linting, builds the app, uploads the Pages artifact, and deploys from ### Alternate Node/Netlify deployment -The static GitHub Pages deployment remains unchanged. For a stateful local Node -deployment, run it through `netlify dev` or set `NETLIFY_DB_URL`, build both the -frontend and server, then -run the compiled HTTP server: +The static GitHub Pages deployment remains unchanged. For stateful local +development, install the Netlify CLI and run Netlify Dev so functions and +Netlify Database use the same runtime model as deploys: ```bash -npm run build:node -npm run start:node +netlify dev ``` -`build:node` and `build:netlify` build the frontend with `VITE_DATA_LAYER=remote`, -`VITE_BASE_PATH=/`, and `VITE_API_BASE_URL=/api`, so the same React app uses the -Node endpoints instead of bundled mutable sample data. The Node server serves -`dist` with SPA fallback and exposes JSON endpoints at `/api/passport`, -`/api/kids`, `/api/wheel-prizes`, and `/api/prizes-kid`. +`build:netlify` builds the frontend with `VITE_DATA_LAYER=remote`, +`VITE_BASE_PATH=/`, and `VITE_API_BASE_URL=/api`, so the same React app uses +function endpoints instead of bundled mutable sample data. `netlify.toml` also routes those endpoints to `netlify/functions/api.ts`. -Netlify Functions and the local Node server use Netlify DB/Postgres for writable -state and staff magic-link token hashes. On startup, the server applies DB -migrations and seeds an empty DB from committed JSON in `src/data`. To force a -non-production reset later, run: +Netlify Functions use Netlify DB/Postgres for writable state and staff +magic-link token hashes. On startup, the function applies DB migrations and +seeds an empty DB from committed JSON in `src/data`. To force a non-production +reset later, run: ```bash NETLIFY_DB_URL=... npm run data:reset-db diff --git a/eslint.config.js b/eslint.config.js index 876d6a6..068c220 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,7 @@ import globals from 'globals'; import tseslint from 'typescript-eslint'; export default tseslint.config( - { ignores: ['dist', 'dist-server'] }, + { ignores: ['.netlify', 'dist', 'dist-server'] }, js.configs.recommended, ...tseslint.configs.recommended, { diff --git a/package-lock.json b/package-lock.json index 20bd8a7..8ed5551 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "kid-a", "version": "0.0.0", "dependencies": { - "@neondatabase/serverless": "^1.1.0", "@netlify/database": "^1.1.0", "@primer/octicons-react": "^19.28.1", "@types/qrcode": "^1.5.6", diff --git a/package.json b/package.json index 388cfee..fc43f2f 100644 --- a/package.json +++ b/package.json @@ -5,18 +5,16 @@ "type": "module", "scripts": { "dev": "vite", + "dev:netlify": "netlify dev", "build": "tsc -b && vite build && node -e \"require('node:fs').copyFileSync('dist/index.html', 'dist/404.html')\"", "build:gh-pages": "npm run build", "build:netlify": "VITE_DATA_LAYER=remote VITE_BASE_PATH=/ VITE_API_BASE_URL=/api npm run build && npm run build:server", - "build:node": "VITE_DATA_LAYER=remote VITE_BASE_PATH=/ VITE_API_BASE_URL=/api npm run build && npm run build:server", "build:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\" && tsc -p tsconfig.server.json", "data:reset-db": "npm run build:server && node dist-server/server/reset-db.js", "lint": "eslint .", - "preview": "vite preview", - "start:node": "node dist-server/server/index.js" + "preview": "vite preview" }, "dependencies": { - "@neondatabase/serverless": "^1.1.0", "@netlify/database": "^1.1.0", "@primer/octicons-react": "^19.28.1", "@types/qrcode": "^1.5.6", diff --git a/server/db-store.ts b/server/db-store.ts index 00f810d..70faf86 100644 --- a/server/db-store.ts +++ b/server/db-store.ts @@ -1,9 +1,4 @@ -import { getConnectionString } from '@netlify/database'; -import type { - NeonQueryFunction, - NeonQueryFunctionInTransaction, -} from '@neondatabase/serverless'; -import { neon } from '@neondatabase/serverless'; +import { getDatabase } from '@netlify/database'; import type { MagicLinkTokenRecord, MagicLinkTokenStore, @@ -30,9 +25,33 @@ import type { StoreData, } from './types.js'; -export type SqlClient = NeonQueryFunction; -type TransactionSql = NeonQueryFunctionInTransaction; type Row = Record; +type Query = { + text: string; + values: unknown[]; +}; +type QueryResult = { + rows: unknown[]; +}; +type PoolClientLike = { + query(text: string, values?: unknown[]): Promise; + release(): void; +}; +type PoolLike = { + connect(): Promise; + query(text: string, values?: unknown[]): Promise; +}; +type TransactionSql = ( + strings: TemplateStringsArray, + ...values: unknown[] +) => Query; +export type SqlClient = { + (strings: TemplateStringsArray, ...values: unknown[]): Promise; + query(text: string, values?: unknown[]): Promise; + transaction( + buildQueries: (sql: TransactionSql) => Query[], + ): Promise; +}; const kidRegistrationRetryDelayMs = 250; const maxKidRegistrationAttempts = 20; @@ -43,21 +62,66 @@ function isPostgresUrl(value: string | undefined): value is string { value?.startsWith('postgresql://') === true; } +function toQuery(strings: TemplateStringsArray, values: unknown[]): Query { + return { + text: strings.reduce( + (sql, segment, index) => + `${sql}${segment}${index < values.length ? `$${index + 1}` : ''}`, + '', + ), + values, + }; +} + +function createPoolSqlClient(pool: PoolLike): SqlClient { + const sql = (async (strings: TemplateStringsArray, ...values: unknown[]) => { + const query = toQuery(strings, values); + const result = await pool.query(query.text, query.values); + return result.rows as Row[]; + }) as SqlClient; + + sql.query = async (text: string, values: unknown[] = []) => { + const result = await pool.query(text, values); + return result.rows as Row[]; + }; + + sql.transaction = async (buildQueries: (tx: TransactionSql) => Query[]) => { + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + const tx: TransactionSql = (strings, ...values) => toQuery(strings, values); + const results: Row[][] = []; + + for (const query of buildQueries(tx)) { + const result = await client.query(query.text, query.values); + results.push(result.rows as Row[]); + } + + await client.query('COMMIT'); + return results; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + }; + + return sql; +} + export function createSqlClient() { const connectionString = [ process.env.NETLIFY_DB_URL, process.env.NETLIFY_DATABASE_URL, process.env.DATABASE_URL, ].find(isPostgresUrl); - const netlifyConnectionString = connectionString ?? getConnectionString(); - - if (!isPostgresUrl(netlifyConnectionString)) { - throw new Error( - 'Netlify Database is not configured with a Postgres connection URL. Run `netlify database status` and ensure the database is enabled.', - ); - } + const database = connectionString + ? getDatabase({ connectionString }) + : getDatabase(); - return neon(netlifyConnectionString); + return createPoolSqlClient(database.pool as unknown as PoolLike); } function delay(ms: number) { diff --git a/server/index.ts b/server/index.ts deleted file mode 100644 index 2fa611b..0000000 --- a/server/index.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { createServer, type IncomingMessage } from 'node:http'; -import { readFile, stat } from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { setMagicTokenStore } from './access-tokens.js'; -import { handleApiRequest, isApiPath } from './api.js'; -import { ensureDbInitialized } from './db-bootstrap.js'; -import { createDbMagicTokenStore, createDbStore } from './db-store.js'; -import { setStoreAdapter } from './store.js'; - -const port = Number(process.env.PORT ?? 3000); -const distDir = path.resolve(process.env.KID_A_DIST_DIR ?? 'dist'); -const currentFilePath = fileURLToPath(import.meta.url); -const serverRoot = path.dirname(currentFilePath); - -setStoreAdapter(createDbStore()); -setMagicTokenStore(createDbMagicTokenStore()); -await ensureDbInitialized(); - -const mimeTypes: Record = { - '.css': 'text/css; charset=utf-8', - '.html': 'text/html; charset=utf-8', - '.ico': 'image/x-icon', - '.jpg': 'image/jpeg', - '.js': 'text/javascript; charset=utf-8', - '.json': 'application/json; charset=utf-8', - '.png': 'image/png', - '.svg': 'image/svg+xml', - '.webp': 'image/webp', -}; - -async function readRequestBody(request: IncomingMessage) { - const chunks: Buffer[] = []; - - for await (const chunk of request) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - - return Buffer.concat(chunks).toString('utf8'); -} - -function resolveStaticPath(requestPath: string) { - const decodedPath = decodeURIComponent(requestPath); - const withoutBasePath = decodedPath.startsWith('/kid-a/') - ? decodedPath.slice('/kid-a'.length) - : decodedPath; - const normalizedPath = withoutBasePath === '/kid-a' ? '/' : withoutBasePath; - const relativePath = normalizedPath === '/' ? 'index.html' : `.${normalizedPath}`; - const resolvedPath = path.resolve(distDir, relativePath); - - if (resolvedPath !== distDir && !resolvedPath.startsWith(`${distDir}${path.sep}`)) { - return path.join(distDir, 'index.html'); - } - - return resolvedPath; -} - -async function getStaticFile(requestPath: string) { - const requestedFile = resolveStaticPath(requestPath); - - try { - const fileStat = await stat(requestedFile); - - if (fileStat.isFile()) { - return requestedFile; - } - } catch { - // Fall through to the SPA entry point. - } - - return path.join(distDir, 'index.html'); -} - -const server = createServer(async (request, response) => { - const requestUrl = new URL(request.url ?? '/', 'http://kid-a.local'); - - if (isApiPath(requestUrl.pathname)) { - const apiResponse = await handleApiRequest({ - body: await readRequestBody(request), - headers: request.headers, - method: request.method ?? 'GET', - url: request.url ?? '/', - }); - - response.writeHead(apiResponse.status, apiResponse.headers); - response.end(apiResponse.body); - return; - } - - if (request.method !== 'GET' && request.method !== 'HEAD') { - response.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' }); - response.end('Method not allowed'); - return; - } - - try { - const staticFile = await getStaticFile(requestUrl.pathname); - const content = request.method === 'HEAD' ? undefined : await readFile(staticFile); - const contentType = - mimeTypes[path.extname(staticFile)] ?? 'application/octet-stream'; - - response.writeHead(200, { 'Content-Type': contentType }); - response.end(content); - } catch (error) { - console.error(error); - response.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }); - response.end('Unable to serve application'); - } -}); - -server.listen(port, () => { - console.log( - `Kid-A Node server listening on http://localhost:${port} (${serverRoot})`, - ); -}); From 18cc42baa664a796f137f358bb43b83d99472840 Mon Sep 17 00:00:00 2001 From: pablonete Date: Thu, 25 Jun 2026 00:10:18 +0200 Subject: [PATCH 7/9] Rely on Netlify Dev for stateful local runtime Remove the custom Node server path and use Netlify Dev for local function/database parity. Configure the local Vite target to call Netlify Functions through the dev proxy and update docs/scripts accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- netlify.toml | 5 +++++ package.json | 1 + 2 files changed, 6 insertions(+) diff --git a/netlify.toml b/netlify.toml index fb64f3c..819d02c 100644 --- a/netlify.toml +++ b/netlify.toml @@ -6,6 +6,11 @@ directory = "netlify/functions" included_files = ["db/migrations/*.sql", "src/data/*.json"] +[dev] + command = "npm run dev:remote" + targetPort = 5173 + port = 8888 + [[redirects]] from = "/api/passport" to = "/.netlify/functions/api/passport" diff --git a/package.json b/package.json index fc43f2f..2d9b399 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "dev:remote": "VITE_DATA_LAYER=remote VITE_BASE_PATH=/ VITE_API_BASE_URL=http://localhost:8888/api vite", "dev:netlify": "netlify dev", "build": "tsc -b && vite build && node -e \"require('node:fs').copyFileSync('dist/index.html', 'dist/404.html')\"", "build:gh-pages": "npm run build", From a63a7a7cb587915831e6efd0b8d4dff9a6e652de Mon Sep 17 00:00:00 2001 From: pablonete Date: Thu, 25 Jun 2026 00:14:53 +0200 Subject: [PATCH 8/9] Use Netlify Database migrations path Move the schema migration into netlify/database/migrations so Netlify Database detects and applies it automatically. Update runtime bootstrap, function bundling, and docs to use the native migration location. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 6 +++--- docs/storage-json.md | 8 ++++---- netlify.toml | 2 +- {db => netlify/database}/migrations/0001_netlify_db.sql | 0 server/db-bootstrap.ts | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) rename {db => netlify/database}/migrations/0001_netlify_db.sql (100%) diff --git a/README.md b/README.md index 6364c42..2a8c942 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,9 @@ function endpoints instead of bundled mutable sample data. `netlify.toml` also routes those endpoints to `netlify/functions/api.ts`. Netlify Functions use Netlify DB/Postgres for writable state and staff -magic-link token hashes. On startup, the function applies DB migrations and -seeds an empty DB from committed JSON in `src/data`. To force a non-production -reset later, run: +magic-link token hashes. Netlify applies migrations from +`netlify/database/migrations`, and the function seeds an empty DB from committed +JSON in `src/data`. To force a non-production reset later, run: ```bash NETLIFY_DB_URL=... npm run data:reset-db diff --git a/docs/storage-json.md b/docs/storage-json.md index 3d596ed..8ac112f 100644 --- a/docs/storage-json.md +++ b/docs/storage-json.md @@ -26,7 +26,7 @@ NETLIFY_DB_URL=... npm run data:reset-db ## Schema -The DB schema lives in `db/migrations/0001_netlify_db.sql`. It keeps app-owned -IDs for kids, prizes, prize awards, and magic-link token hashes. The database -does not auto-generate user-facing IDs or maintain a separate kid ID counter -table. +The DB schema lives in `netlify/database/migrations/0001_netlify_db.sql` so +Netlify Database can detect and apply it during deploys. It keeps app-owned IDs +for kids, prizes, prize awards, and magic-link token hashes. The database does +not auto-generate user-facing IDs or maintain a separate kid ID counter table. diff --git a/netlify.toml b/netlify.toml index 819d02c..49437e8 100644 --- a/netlify.toml +++ b/netlify.toml @@ -4,7 +4,7 @@ [functions] directory = "netlify/functions" - included_files = ["db/migrations/*.sql", "src/data/*.json"] + included_files = ["netlify/database/migrations/*.sql", "src/data/*.json"] [dev] command = "npm run dev:remote" diff --git a/db/migrations/0001_netlify_db.sql b/netlify/database/migrations/0001_netlify_db.sql similarity index 100% rename from db/migrations/0001_netlify_db.sql rename to netlify/database/migrations/0001_netlify_db.sql diff --git a/server/db-bootstrap.ts b/server/db-bootstrap.ts index 6b8dae2..6a277b9 100644 --- a/server/db-bootstrap.ts +++ b/server/db-bootstrap.ts @@ -16,7 +16,7 @@ import type { StoreData, } from './types.js'; -const migrationsDir = path.resolve('db/migrations'); +const migrationsDir = path.resolve('netlify/database/migrations'); const seedDataDir = path.resolve(process.env.KID_A_SEED_DATA_DIR ?? 'src/data'); let initializationPromise: Promise | undefined; From 776170d22097decd08b45fabbd64a12dfbae5ba2 Mon Sep 17 00:00:00 2001 From: pablonete Date: Thu, 25 Jun 2026 00:21:33 +0200 Subject: [PATCH 9/9] Use modern Netlify function API route Convert the API function from Lambda compatibility handler exports to the modern Request/Response function format with a direct /api/* config path, so Netlify Database can provide runtime connection context. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- netlify.toml | 54 ---------------------------------------- netlify/functions/api.ts | 31 +++++++++++------------ 2 files changed, 14 insertions(+), 71 deletions(-) diff --git a/netlify.toml b/netlify.toml index 49437e8..34029c5 100644 --- a/netlify.toml +++ b/netlify.toml @@ -11,60 +11,6 @@ targetPort = 5173 port = 8888 -[[redirects]] - from = "/api/passport" - to = "/.netlify/functions/api/passport" - status = 200 - force = true - -[[redirects]] - from = "/api/wheel-prizes" - to = "/.netlify/functions/api/wheel-prizes" - status = 200 - force = true - -[[redirects]] - from = "/api/prizes-kid" - to = "/.netlify/functions/api/prizes-kid" - status = 200 - force = true - -[[redirects]] - from = "/api/kids" - to = "/.netlify/functions/api/kids" - status = 200 - force = true - -[[redirects]] - from = "/api/auth/session" - to = "/.netlify/functions/api/auth/session" - status = 200 - force = true - -[[redirects]] - from = "/api/admin/session" - to = "/.netlify/functions/api/admin/session" - status = 200 - force = true - -[[redirects]] - from = "/api/admin/magic-links" - to = "/.netlify/functions/api/admin/magic-links" - status = 200 - force = true - -[[redirects]] - from = "/api/admin/export" - to = "/.netlify/functions/api/admin/export" - status = 200 - force = true - -[[redirects]] - from = "/api/admin/import" - to = "/.netlify/functions/api/admin/import" - status = 200 - force = true - [[redirects]] from = "/kid-a" to = "/" diff --git a/netlify/functions/api.ts b/netlify/functions/api.ts index d378065..7ecde57 100644 --- a/netlify/functions/api.ts +++ b/netlify/functions/api.ts @@ -4,32 +4,29 @@ import { ensureDbInitialized } from '../../server/db-bootstrap.js'; import { createDbMagicTokenStore, createDbStore } from '../../server/db-store.js'; import { setStoreAdapter } from '../../server/store.js'; -type NetlifyEvent = { - body?: string | null; - headers?: Record; - httpMethod: string; - rawUrl?: string; - path: string; -}; - function configureStores() { setStoreAdapter(createDbStore()); setMagicTokenStore(createDbMagicTokenStore()); } -export async function handler(event: NetlifyEvent) { +export default async function handler(request: Request) { await ensureDbInitialized(); configureStores(); const response = await handleApiRequest({ - body: event.body, - headers: event.headers, - method: event.httpMethod, - url: event.rawUrl ?? event.path, + body: request.method === 'GET' || request.method === 'HEAD' + ? undefined + : await request.text(), + headers: Object.fromEntries(request.headers), + method: request.method, + url: request.url, }); - return { - body: response.body, + return new Response(response.body, { headers: response.headers, - statusCode: response.status, - }; + status: response.status, + }); } + +export const config = { + path: '/api/*', +};