diff --git a/apps/api/src/gateway/gateway.service.ts b/apps/api/src/gateway/gateway.service.ts index a67bcadc7..6f40e6940 100644 --- a/apps/api/src/gateway/gateway.service.ts +++ b/apps/api/src/gateway/gateway.service.ts @@ -3,7 +3,7 @@ import type { webcrypto } from 'node:crypto'; import { HybridCrypto } from '@douglasneuroinformatics/libcrypto'; import { LoggingService } from '@douglasneuroinformatics/libnest'; import { HttpService } from '@nestjs/axios'; -import { BadGatewayException, HttpStatus, Injectable } from '@nestjs/common'; +import { BadGatewayException, HttpStatus, Injectable, InternalServerErrorException } from '@nestjs/common'; import { $MutateAssignmentResponseBody, $RemoteAssignment } from '@opendatacapture/schemas/assignment'; import type { Assignment, @@ -12,18 +12,20 @@ import type { RemoteAssignment } from '@opendatacapture/schemas/assignment'; import { $GatewayHealthcheckSuccessResult } from '@opendatacapture/schemas/gateway'; -import type { GatewayHealthcheckFailureResult, GatewayHealthcheckResult } from '@opendatacapture/schemas/gateway'; +import type { + GatewayHealthcheckFailureResult, + GatewayHealthcheckResult, + RemoteSetupState +} from '@opendatacapture/schemas/gateway'; import { InstrumentsService } from '@/instruments/instruments.service'; -import { SetupService } from '@/setup/setup.service'; @Injectable() export class GatewayService { constructor( private readonly httpService: HttpService, private readonly instrumentsService: InstrumentsService, - private readonly loggingService: LoggingService, - private readonly setupService: SetupService + private readonly loggingService: LoggingService ) {} async createRemoteAssignment( @@ -31,12 +33,8 @@ export class GatewayService { publicKey: webcrypto.CryptoKey ): Promise { const instrument = await this.instrumentsService.findBundleById(assignment.instrumentId); - // The gateway cannot read this instance's setup state, so the languages it may offer this - // patient are sent with the assignment. - const { activeLanguages } = await this.setupService.getState(); const response = await this.httpService.axiosRef.post(`/api/assignments`, { ...assignment, - activeLanguages, instrumentContainer: instrument, publicKey: Array.from(await HybridCrypto.serializePublicKey(publicKey)) } satisfies CreateRemoteAssignmentInputData); @@ -106,4 +104,19 @@ export class GatewayService { } return result.data; } + + /** + * Replace the setup state held by the gateway. The gateway cannot reach this instance, so it + * learns of a change only by being told; it is told on every synchronization pass rather than + * only when the state changes, which is what lets a gateway that missed the change — restarting, + * unreachable, redeployed with an empty in-memory copy — catch up on its own. + */ + async updateRemoteSetupState(setupState: RemoteSetupState): Promise { + const response = await this.httpService.axiosRef.put(`/api/setup-state`, setupState); + if (response.status !== HttpStatus.NO_CONTENT) { + throw new InternalServerErrorException(`Unexpected Status Code From Gateway: ${response.status}`, { + cause: response.statusText + }); + } + } } diff --git a/apps/api/src/gateway/gateway.synchronizer.ts b/apps/api/src/gateway/gateway.synchronizer.ts index 497b110f5..b6b35eddd 100644 --- a/apps/api/src/gateway/gateway.synchronizer.ts +++ b/apps/api/src/gateway/gateway.synchronizer.ts @@ -45,6 +45,18 @@ export class GatewaySynchronizer implements OnApplicationBootstrap { } this.loggingService.log('Synchronizing with gateway...'); + + // A failure here must not stop assignments from synchronizing: the two are independent, and + // the next pass sends the state again. + try { + await this.gatewayService.updateRemoteSetupState({ activeLanguages: setupState.activeLanguages }); + } catch (err) { + this.loggingService.error({ + cause: err, + error: 'Failed to Update Remote Setup State' + }); + } + let remoteAssignments: RemoteAssignment[]; try { remoteAssignments = await this.gatewayService.fetchRemoteAssignments(); diff --git a/apps/gateway/AGENTS.md b/apps/gateway/AGENTS.md index 94e584259..cb6b98fd9 100644 --- a/apps/gateway/AGENTS.md +++ b/apps/gateway/AGENTS.md @@ -5,6 +5,14 @@ rendered. `apps/api` is its only programmatic client — `apps/api/src/gateway/g creates, fetches and deletes assignments here over HTTP, and the patient is given a link to `/assignments/:id`. +**Traffic only ever flows API → gateway.** This app holds no address or credential for the API, so +it cannot ask for anything: whatever it needs to know about the instance it serves has to be pushed. +`PUT /api/setup-state` is that push, and `GatewaySynchronizer` in `apps/api` sends it on every pass +(every `GATEWAY_REFRESH_INTERVAL`, unconditionally) rather than only when the state changes. That is +what makes this app's copy safe to keep in memory: whatever it missed — the change itself, or its +whole copy across a restart — it is told again within one interval, so the window on +`DEFAULT_ACTIVE_LANGUAGES` is bounded without either side tracking what the other holds. + Read the root `AGENTS.md` first for the rules that apply everywhere. **There is no client-side router, no TanStack Query and no Zustand.** Express routing @@ -61,12 +69,14 @@ Its own SQLite database, entirely separate from the API's MongoDB: `prisma/schem (`RemoteAssignmentModel`), generated to `node_modules/@prisma/generated-client` and wrapped in `src/lib/prisma.ts` with a computed `getPublicKey()`. -A `Json` column is typed by `prisma-json-types-generator`, the same generator `apps/api` uses: a -`/// [TypeName]` docstring above the field names a type from the `PrismaJson` namespace declared in -`src/typings/prisma-json-types-generator.d.ts`, and the generated client uses it on both the read -and the write side. **The name must exist in that namespace** — the generator emits the reference -either way, so a typo surfaces as an unresolved type in the generated client rather than an error -from `prisma generate`. +**SQLite has no array or JSON column type**, so a non-scalar is serialized into a `String` and parsed +back on read — `targetStringified`, validated with `$InstrumentBundleContainer` in +`src/routers/root.router.ts`. + +Everything else this app knows is in memory, and deliberately: the assignment verification set +(`src/lib/assignment-verification.ts`) and the instance's setup state (`src/lib/setup-state.ts`). +Neither is a record of something that happened here — one is a short-lived gate, the other a copy of +state `apps/api` owns — so persisting either would only add a second copy that can disagree. `GATEWAY_DATABASE_URL` is an absolute `file:` URL written by `pnpm generate:env`. Turbo runs `db:push` before `dev`, `lint`, `test:e2e` and — via the `@opendatacapture/gateway#build` key in @@ -90,8 +100,8 @@ hydrated tree can disagree with the SSR'd HTML until you do. ## Conventions specific to here - `@/` aliases `src/`, declared in both `vite.config.ts` and `tsconfig.json`. -- Ambient declarations live in `src/typings/` and `src/vite-env.d.ts`: the `PrismaJson` namespace, - `res.locals.loadRoot`, `window.__ROOT_PROPS__`, the `cap-widget` JSX element, `__RELEASE__`. +- Ambient declarations live in `src/typings/` and `src/vite-env.d.ts`: `res.locals.loadRoot`, + `window.__ROOT_PROPS__`, the `cap-widget` JSX element, `__RELEASE__`. - The eslint blocks for `apps/web` and `packages/react-core` (no default exports, no bare `clsx`, `jsx-no-literals`) **do not cover this app**, and default exports are in use. Translation is still required, and there are no translation resource files — `src/services/i18n.ts` initializes with diff --git a/apps/gateway/package.json b/apps/gateway/package.json index d1d158b65..297ce32fa 100644 --- a/apps/gateway/package.json +++ b/apps/gateway/package.json @@ -50,7 +50,6 @@ "esbuild": "catalog:", "nodemon": "catalog:", "prisma": "catalog:", - "prisma-json-types-generator": "^3.2.2", "tailwindcss": "catalog:", "tsx": "catalog:", "type-fest": "workspace:type-fest__4.x@*", diff --git a/apps/gateway/prisma/schema.prisma b/apps/gateway/prisma/schema.prisma index de52ff809..8ebf4bcb1 100644 --- a/apps/gateway/prisma/schema.prisma +++ b/apps/gateway/prisma/schema.prisma @@ -7,10 +7,6 @@ generator client { binaryTargets = ["native", "debian-openssl-1.1.x"] } -generator json { - provider = "prisma-json-types-generator" -} - datasource db { provider = "sqlite" url = env("GATEWAY_DATABASE_URL") @@ -18,9 +14,6 @@ datasource db { model RemoteAssignmentModel { id String @id - /// [ActiveLanguages] - // The default covers assignments created before an instance could choose which languages to offer. - activeLanguages Json @default("[\"en\",\"fr\"]") createdAt DateTime @default(now()) completedAt DateTime? expiresAt DateTime diff --git a/apps/gateway/src/lib/setup-state.ts b/apps/gateway/src/lib/setup-state.ts new file mode 100644 index 000000000..37165c03e --- /dev/null +++ b/apps/gateway/src/lib/setup-state.ts @@ -0,0 +1,21 @@ +import { DEFAULT_ACTIVE_LANGUAGES } from '@opendatacapture/schemas/core'; +import type { ActiveLanguages } from '@opendatacapture/schemas/core'; +import type { RemoteSetupState } from '@opendatacapture/schemas/gateway'; + +/** + * The setup state of the instance this gateway serves, as last pushed by `apps/api`. + * + * Held in memory, consistent with the verification set in `assignment-verification.ts`. It is a + * copy of state the API owns rather than a record of anything that happens here, and the API + * re-sends it every `GATEWAY_REFRESH_INTERVAL`, so a restart costs at most one interval on the + * defaults below. + */ +let setupState: null | RemoteSetupState = null; + +export function getActiveLanguages(): ActiveLanguages { + return setupState?.activeLanguages ?? DEFAULT_ACTIVE_LANGUAGES; +} + +export function updateSetupState(state: RemoteSetupState): void { + setupState = state; +} diff --git a/apps/gateway/src/routers/api.router.ts b/apps/gateway/src/routers/api.router.ts index fcf4a4de4..6b64db71e 100644 --- a/apps/gateway/src/routers/api.router.ts +++ b/apps/gateway/src/routers/api.router.ts @@ -5,11 +5,13 @@ import type { MutateAssignmentResponseBody, RemoteAssignment } from '@opendatacapture/schemas/assignment'; +import { $RemoteSetupState } from '@opendatacapture/schemas/gateway'; import type { GatewayHealthcheckSuccessResult } from '@opendatacapture/schemas/gateway'; import { Router } from 'express'; import { clearAssignmentVerification, isAssignmentVerified } from '@/lib/assignment-verification'; import { prisma } from '@/lib/prisma'; +import { updateSetupState } from '@/lib/setup-state'; import { logger } from '@/logger'; import { ah } from '@/utils/async-handler'; import { HttpException } from '@/utils/http-exception'; @@ -47,12 +49,11 @@ router.post( logger.error(result.error.issues); throw new HttpException(400, 'Bad Request'); } - const { activeLanguages, instrumentContainer, publicKey, ...assignment } = result.data; + const { instrumentContainer, publicKey, ...assignment } = result.data; await prisma.remoteAssignmentModel.create({ data: { ...assignment, - activeLanguages, rawPublicKey: Buffer.from(publicKey), targetStringified: JSON.stringify(instrumentContainer) } @@ -139,6 +140,21 @@ router.delete( }) ); +// A full replacement rather than a patch, so a retry, a duplicate and the periodic reconcile in +// `apps/api` are all the same request. +router.put( + '/setup-state', + ah(async (req, res) => { + const result = await $RemoteSetupState.safeParseAsync(req.body); + if (!result.success) { + logger.error(result.error.issues); + throw new HttpException(400, 'Bad Request'); + } + updateSetupState(result.data); + res.status(204).end(); + }) +); + router.get('/healthcheck', (_, res) => { res.status(200).json({ ok: true, diff --git a/apps/gateway/src/routers/root.router.ts b/apps/gateway/src/routers/root.router.ts index 1ede9cb45..5df44ff43 100644 --- a/apps/gateway/src/routers/root.router.ts +++ b/apps/gateway/src/routers/root.router.ts @@ -3,6 +3,7 @@ import { $InstrumentBundleContainer } from '@opendatacapture/schemas/instrument' import { Router } from 'express'; import { prisma } from '@/lib/prisma'; +import { getActiveLanguages } from '@/lib/setup-state'; import { logger } from '@/logger'; import type { RootProps } from '@/Root'; import { ah } from '@/utils/async-handler'; @@ -43,7 +44,7 @@ router.get( // Read server-side rather than from `window.location` so the SSR pass and the hydration pass // resolve the same language; anything else renders the page in English and then swaps it. - const { activeLanguages } = assignment; + const activeLanguages = getActiveLanguages(); const requestedLanguage = $Language.safeParse(req.query.lang); const language = requestedLanguage.success && activeLanguages.includes(requestedLanguage.data) diff --git a/apps/gateway/src/typings/prisma-json-types-generator.d.ts b/apps/gateway/src/typings/prisma-json-types-generator.d.ts deleted file mode 100644 index c3a5d1081..000000000 --- a/apps/gateway/src/typings/prisma-json-types-generator.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { ActiveLanguages as SchemaActiveLanguages } from '@opendatacapture/schemas/core'; - -declare global { - namespace PrismaJson { - type ActiveLanguages = SchemaActiveLanguages; - } -} diff --git a/package.json b/package.json index 5b128f785..99a47d108 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "opendatacapture", "type": "module", - "version": "2.2.1", + "version": "2.2.2", "private": true, "packageManager": "pnpm@10.34.3", "license": "Apache-2.0", diff --git a/packages/instrument-bundler/package.json b/packages/instrument-bundler/package.json index a423924f8..ada48d5fa 100644 --- a/packages/instrument-bundler/package.json +++ b/packages/instrument-bundler/package.json @@ -1,7 +1,7 @@ { "name": "@opendatacapture/instrument-bundler", "type": "module", - "version": "2.2.1", + "version": "2.2.2", "sideEffects": [ "**/cli.ts" ], diff --git a/packages/instrument-guidelines/package.json b/packages/instrument-guidelines/package.json index a815d9360..225346e99 100644 --- a/packages/instrument-guidelines/package.json +++ b/packages/instrument-guidelines/package.json @@ -1,7 +1,7 @@ { "name": "@opendatacapture/instrument-guidelines", "type": "module", - "version": "2.2.1", + "version": "2.2.2", "description": "Guidelines for authoring Open Data Capture instruments, intended to be read by an AI agent (e.g. Claude Code).", "license": "Apache-2.0", "publishConfig": { diff --git a/packages/playground-url/package.json b/packages/playground-url/package.json index cb61b3d6c..887291f76 100644 --- a/packages/playground-url/package.json +++ b/packages/playground-url/package.json @@ -1,7 +1,7 @@ { "name": "@opendatacapture/playground-url", "type": "module", - "version": "2.2.1", + "version": "2.2.2", "sideEffects": [ "**/cli.ts" ], diff --git a/packages/schemas/src/assignment/assignment.ts b/packages/schemas/src/assignment/assignment.ts index 9262b8910..98d09cded 100644 --- a/packages/schemas/src/assignment/assignment.ts +++ b/packages/schemas/src/assignment/assignment.ts @@ -1,7 +1,7 @@ import { $Uint8ArrayLike } from '@douglasneuroinformatics/libjs'; import { z } from 'zod/v4'; -import { $ActiveLanguages, $BaseModel, $Json } from '../core/core.js'; +import { $BaseModel, $Json } from '../core/core.js'; import { $InstrumentBundleContainer } from '../instrument/instrument.base.js'; export const $AssignmentStatus = z.enum(['CANCELED', 'COMPLETE', 'EXPIRED', 'OUTSTANDING']); @@ -39,16 +39,9 @@ export const $CreateAssignmentData = z.object({ subjectId: z.string() }); -/** - * The DTO transferred from the core API to the external gateway when creating an assignment. - * - * The gateway has its own database and cannot read the instance's setup state, so the languages it - * may offer this patient travel with the assignment. They are a snapshot taken at creation: an - * assignment already sent keeps the languages it was created with. - */ +/** The DTO transferred from the core API to the external gateway when creating an assignment. */ export type CreateRemoteAssignmentInputData = z.input; export const $CreateRemoteAssignmentData = $RemoteAssignment.omit({ encryptedData: true, symmetricKey: true }).extend({ - activeLanguages: $ActiveLanguages, instrumentContainer: $InstrumentBundleContainer, publicKey: $Uint8ArrayLike }); diff --git a/packages/schemas/src/gateway/gateway.ts b/packages/schemas/src/gateway/gateway.ts index 58d93fda5..0ea26ee5c 100644 --- a/packages/schemas/src/gateway/gateway.ts +++ b/packages/schemas/src/gateway/gateway.ts @@ -1,7 +1,20 @@ import { z } from 'zod/v4'; +import { $ActiveLanguages } from '../core/core.js'; import { $ReleaseInfo } from '../setup/setup.js'; +/** + * The instance-level state the gateway needs in order to render an assignment, pushed by `apps/api` + * on every synchronization pass. Deliberately a projection of `SetupState` rather than the whole + * shape: the rest of it either describes the API's own process (`release`, `uptime`) or gates UI the + * gateway does not have, and the gateway is reachable from outside the instance's network, so it + * should hold only what it renders. + */ +export type RemoteSetupState = z.infer; +export const $RemoteSetupState = z.object({ + activeLanguages: $ActiveLanguages +}); + export type GatewayHealthcheckSuccessResult = z.infer; export const $GatewayHealthcheckSuccessResult = z.object({ ok: z.literal(true), diff --git a/packages/serve-instrument/package.json b/packages/serve-instrument/package.json index 539fa07e3..391d183c6 100644 --- a/packages/serve-instrument/package.json +++ b/packages/serve-instrument/package.json @@ -1,7 +1,7 @@ { "name": "@opendatacapture/serve-instrument", "type": "module", - "version": "2.2.1", + "version": "2.2.2", "license": "Apache-2.0", "publishConfig": { "access": "public" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6680d110c..448760668 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -469,9 +469,6 @@ importers: prisma: specifier: 'catalog:' version: 6.19.3(typescript@6.0.3) - prisma-json-types-generator: - specifier: ^3.2.2 - version: 3.6.2(@prisma/client@6.19.3(prisma@6.19.3(typescript@6.0.3))(typescript@6.0.3))(prisma@6.19.3(typescript@6.0.3))(typescript@6.0.3) tailwindcss: specifier: 'catalog:' version: 4.3.0 diff --git a/runtime/v1/package.json b/runtime/v1/package.json index b5f59beec..28d7070a2 100644 --- a/runtime/v1/package.json +++ b/runtime/v1/package.json @@ -1,7 +1,7 @@ { "name": "@opendatacapture/runtime-v1", "type": "module", - "version": "2.2.1", + "version": "2.2.2", "author": { "name": "Douglas Neuroinformatics", "email": "support@douglasneuroinformatics.ca"