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 2cdfa87..2a8c942 100644 --- a/README.md +++ b/README.md @@ -37,44 +37,34 @@ 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: +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`. 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. +`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 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 use Netlify DB/Postgres for writable state and staff +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 +``` + +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..8ac112f 100644 --- a/docs/storage-json.md +++ b/docs/storage-json.md @@ -1,77 +1,32 @@ -# 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. On startup, the server applies DB migrations and seeds an empty DB from +committed JSON files in `src/data`. -- 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 force a +reset from committed seed JSON and clear magic-link tokens, run: ```sh -NETLIFY_DATABASE_URL=... npm run data:reset-stores +NETLIFY_DB_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 `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/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/netlify.toml b/netlify.toml index b60dad9..34029c5 100644 --- a/netlify.toml +++ b/netlify.toml @@ -4,61 +4,12 @@ [functions] directory = "netlify/functions" - included_files = ["server/data/*.json", "src/data/*.json"] + included_files = ["netlify/database/migrations/*.sql", "src/data/*.json"] -[[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 +[dev] + command = "npm run dev:remote" + targetPort = 5173 + port = 8888 [[redirects]] from = "/kid-a" 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/netlify/functions/api.ts b/netlify/functions/api.ts index 6c322ab..7ecde57 100644 --- a/netlify/functions/api.ts +++ b/netlify/functions/api.ts @@ -1,89 +1,32 @@ -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 { ensureDbInitialized } from '../../server/db-bootstrap.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; - rawUrl?: string; - 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), - }); - } - +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/*', +}; diff --git a/package-lock.json b/package-lock.json index d984e6b..8ed5551 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,7 @@ "name": "kid-a", "version": "0.0.0", "dependencies": { - "@netlify/blobs": "^10.7.9", - "@netlify/neon": "^0.1.2", + "@netlify/database": "^1.1.0", "@primer/octicons-react": "^19.28.1", "@types/qrcode": "^1.5.6", "jsqr": "^1.4.0", @@ -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,68 +286,20 @@ "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", - "integrity": "sha512-F8+Dnu+nvGZruskblFwTopv/CUbHi2VOP4bJpDhRo85UFawZ/wMPmxnWQ0CLYwdAERU1OYoQyXOQM19HxAHjQQ==", - "license": "ISC", - "dependencies": { - "@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==", + "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": { - "@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" + "@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": "^18.14.0 || >=20.6.1" + "node": ">=20.6.1" } }, "node_modules/@netlify/runtime-utils": { @@ -379,130 +311,6 @@ "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 +923,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 +936,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 +987,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 +1010,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 +1019,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 +1087,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 +1101,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 +1127,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 +1454,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 +1503,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 +1847,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 +1939,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", @@ -2381,6 +1958,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", @@ -2439,6 +2105,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", @@ -2535,19 +2240,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 +2249,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 +2299,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" @@ -2673,6 +2353,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", @@ -2699,21 +2388,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 +2405,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 +2422,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 +2439,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 +2493,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,11 +2571,93 @@ } } }, - "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/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", @@ -2983,6 +2705,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 84fdaff..2d9b399 100644 --- a/package.json +++ b/package.json @@ -5,19 +5,18 @@ "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", "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" + "preview": "vite preview" }, "dependencies": { - "@netlify/blobs": "^10.7.9", - "@netlify/neon": "^0.1.2", + "@netlify/database": "^1.1.0", "@primer/octicons-react": "^19.28.1", "@types/qrcode": "^1.5.6", "jsqr": "^1.4.0", 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/reset-stores.ts b/server/db-bootstrap.ts similarity index 54% rename from server/reset-stores.ts rename to server/db-bootstrap.ts index 819a444..6a277b9 100644 --- a/server/reset-stores.ts +++ b/server/db-bootstrap.ts @@ -1,14 +1,12 @@ 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'; + createDbMagicTokenStore, + createDbStore, + createSqlClient, + type SqlClient, +} from './db-store.js'; +import { getStoreFileName, syncPrizeGivenCache } from './store.js'; import type { ConferenceData, Kid, @@ -18,10 +16,9 @@ import type { StoreData, } from './types.js'; -type SqlClient = ReturnType; - -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; async function readSeedJson(fileName: string): Promise { return JSON.parse(await readFile(path.join(seedDataDir, fileName), 'utf8')) as T; @@ -55,7 +52,7 @@ function splitSqlStatements(sqlText: string) { .filter(Boolean); } -async function applySchema(sql: SqlClient) { +export async function applyDbSchema(sql: SqlClient = createSqlClient()) { const migrationFiles = (await readdir(migrationsDir)) .filter((fileName) => fileName.endsWith('.sql')) .sort(); @@ -69,37 +66,34 @@ 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'], - ); +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 }>; - console.log(`Reset ${name} store from ${seedDataDir}`); + return rows[0]?.has_conference === true; } -async function main() { - const sql = neon(); +export async function resetDb(sql: SqlClient = createSqlClient()) { + 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 applyDbSchema(sql); + await createDbStore(sql).resetData(seedSnapshot); + await createDbMagicTokenStore(sql).writeTokens([]); } -main().catch((error: unknown) => { - console.error(error); - process.exitCode = 1; -}); +export async function ensureDbInitialized(sql: SqlClient = createSqlClient()) { + initializationPromise ??= (async () => { + await applyDbSchema(sql); + + if (!(await hasSeedData(sql))) { + await resetDb(sql); + } + })(); + + return initializationPromise; +} diff --git a/server/db-store.ts b/server/db-store.ts index 56f8b56..70faf86 100644 --- a/server/db-store.ts +++ b/server/db-store.ts @@ -1,8 +1,4 @@ -import { neon } from '@netlify/neon'; -import type { - NeonQueryFunctionInTransaction, - NeonQueryInTransaction, -} from '@neondatabase/serverless'; +import { getDatabase } from '@netlify/database'; import type { MagicLinkTokenRecord, MagicLinkTokenStore, @@ -17,7 +13,6 @@ import { type RegisterKidCommand, type SavePrizeCommand, type StoreAdapter, - type StoreFile, type WritableStoreData, } from './store.js'; import type { @@ -30,14 +25,105 @@ import type { StoreData, } from './types.js'; -type SqlClient = ReturnType; -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; const generatedIdLookahead = 1000; +function isPostgresUrl(value: string | undefined): value is string { + return value?.startsWith('postgres://') === true || + 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 database = connectionString + ? getDatabase({ connectionString }) + : getDatabase(); + + return createPoolSqlClient(database.pool as unknown as PoolLike); +} + function delay(ms: number) { return new Promise((resolve) => { setTimeout(resolve, ms); @@ -235,109 +321,7 @@ 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 { +export function createDbStore(sql: SqlClient = createSqlClient()): StoreAdapter { async function readSnapshot(): Promise { const [conferenceRows, kidRows, passportRows, prizeRows, prizeAwardRows] = await Promise.all([ @@ -613,52 +597,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 +642,33 @@ export function createDbStore(sql: SqlClient = neon()): StoreAdapter { completePassportActivity, readSnapshot, registerKid, + resetData, restoreWritableData, savePrize, - updatePassportForKid, - updatePrizeAwardsForKid, - updateSnapshot, }; } -export function createDbMagicTokenStore(sql: SqlClient = neon()) { +export function createDbMagicTokenStore(sql: SqlClient = createSqlClient()) { 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 deleted file mode 100644 index 2559325..0000000 --- a/server/index.ts +++ /dev/null @@ -1,107 +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 { handleApiRequest, isApiPath } from './api.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); - -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})`, - ); -}); diff --git a/server/reset-db.ts b/server/reset-db.ts new file mode 100644 index 0000000..977cb36 --- /dev/null +++ b/server/reset-db.ts @@ -0,0 +1,12 @@ +import { resetDb } from './db-bootstrap.js'; +import { createSqlClient } from './db-store.js'; + +resetDb(createSqlClient()) + .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; + }); 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); }