Repository navigation
Description
On every sync pass, GET /api/assignments returns every stored assignment with all of its columns. That includes targetStringified, the full compiled instrument bundle (every item's bundle, for a series), and rawPublicKey. The API throws both away. GatewayService.fetchRemoteAssignments parses the body with $RemoteAssignment, which has neither field, and GatewaySynchronizer then skips every OUTSTANDING row without reading anything else. The API calls this endpoint every GATEWAY_REFRESH_INTERVAL (10 s by default), so the gateway serializes, sends and the API parses one copy of the bundle per outstanding assignment, continuously, only to discard it. POST /api/assignments/bulk writes the bundle into every row it creates, so the payload grows with the number of subjects, not the number of instruments. At large enough scale this stops the sync altogether. Once the JSON body passes V8's maximum string length (536,870,888 characters on Node 24), res.json throws RangeError: Invalid string length. The endpoint then returns 500, and the synchronizer logs "Failed to Fetch Remote Assignments" and gives up the pass. Because the failure recurs on every pass, no completed assignment is imported until enough outstanding ones are removed.
Where
apps/gateway/src/routers/api.router.ts:25-38:
router.get(
'/assignments',
ah(async (_, res) => {
const assignments = await prisma.remoteAssignmentModel.findMany();
return res.status(200).json(
assignments.map((assignment) => {
return {
...assignment,
status: assignment.status as AssignmentStatus
};
}) satisfies RemoteAssignment[]
);
})
);The consumer, apps/api/src/gateway/gateway.service.ts:97-114 and gateway.synchronizer.ts:83-89, keeps only the $RemoteAssignment fields and continues on OUTSTANDING.
Reproduce
Mechanism and scale: the size of each row is dominated by its bundle. The built-in Enhanced Demographics Questionnaire serializes to about 19 KB per row, of which the fields the API keeps are about 400 bytes; the bundle is escaped twice, once in the column and again by res.json. One bulk operation (BULK_ASSIGNMENT_MAX_SUBJECTS = 500) therefore adds about 9.4 MB that the gateway sends and the API parses every 10 s while those assignments are outstanding, about 0.9 MB/s of steady waste for a single form. The instrument bundler inlines .png, .mp3 and .mp4 assets as data URLs, so an interactive task with media can easily be 1 MB. Roughly 540 outstanding assignments of such a task, across one or two bulk operations, push the response past the 512 MiB string limit, and from then on every sync pass fails.
- Bulk-assign an instrument to many subjects and leave the assignments outstanding.
- Watch the gateway's
GET /api/assignmentsresponse size and the API's sync duration on each pass.
Actual: the response grows by one full bundle per outstanding assignment, and past the string limit it becomes a 500 that blocks every import.
Expected: the response carries only the fields $RemoteAssignment defines, a few hundred bytes per row, however large the instruments are.
Tests
apps/gateway/src/routers/__tests__/api.router.test.ts:it('should list assignments without their instrument bundles or public keys, so a sync pass does not transfer every bundle'), asserting thatfindManyis called with anomit(orselect) oftargetStringifiedandrawPublicKey, and that the response rows lack them.- e2e,
testing/src/specs/gateway-assignment.spec.ts: seed an assignment, callGET ${gatewayURL}/api/assignmentswith the gateway API key (exposeGATEWAY_API_KEYfromtesting/src/support/env.ts), and assert that the seeded row has notargetStringified.
Suggested fix
Pass omit: { rawPublicKey: true, targetStringified: true } (or an explicit select of the $RemoteAssignment fields) to findMany. A further option is to agree with the API on a where: { status: { not: 'OUTSTANDING' } } filter, since the synchronizer ignores outstanding rows. That changes what the endpoint means, though, and would need #1703's expiry handling to read expiresAt some other way.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). The handler atapi.router.ts:25-38last changed in 3cb429e, before this was filed, and no open PR touches the file.Use a typed
select, notomitrawPublicKeyis not small. Prisma 6.19.3 decodesBytesinto a plainUint8Array, which has notoJSON, sores.jsonwrites the 65-byte key as{"0":4,"1":...}: about 550 bytes, more than the ~400 bytes the API keeps.The
satisfies RemoteAssignment[]atapi.router.ts:35cannot catch extra columns. It checks the arraymapreturns, not an object literal, so no excess-property check runs. A select typed against the schema does catch them:A missing or undeclared key is then a type error, and a column added to
RemoteAssignmentModellater stays off the wire.omitis a denylist, andomit: { rawPublicKey: true }still reads the column:getPublicKeyinapps/gateway/src/lib/prisma.tslists it inneeds, so Prisma drops it from the query's omit and strips it from the result afterwards.Leave out the
status: { not: 'OUTSTANDING' }filter. With the select an outstanding row is a few hundred bytes, and the fix suggested in #1703 needs those rows.Tests
api.router.test.tsmocks@/lib/prisma, so asserting that response rows lack the columns proves nothing; the rows are whatever the test hands the mock. Assert the query. The case at lines 150-155 expectsfindManytoHaveBeenCalledWith()and will fail; change it to expect aselectwhose keys equal$RemoteAssignment.keyof().options.seedAssignment(api)(gateway-assignment.spec.ts:53) creates an outstanding row the synchronizer leaves alone, andapiRequestContextalready calls the gateway by absolute URL (line 162). Find the seeded row by id and assert its sorted keys equalObject.keys($RemoteAssignment.shape).sort(), which also coversrawPublicKeyand future columns.GATEWAY_API_KEYis in every generated.env(scripts/generate-env.sh, which CI runs), sorequired('GATEWAY_API_KEY')intesting/src/support/env.tsis safe.Out of scope
The other handlers in
api.router.tsalso load the bundle without using it (findFirstat :106 and :162, and the rowscreate,updateanddeletereturn), but once per request, so they can follow separately. #1729 is independent and still needed for the server-rendered page.Triage note written with Claude Code. Code references point at
main@d86740e.