Repository navigation
Description
PATCH /v1/instrument-records/:id rejects every update to a record whose instrument has a date (or set) field, even when the update does not touch that field. create stores data through serializeData, which runs the libjs replacer and writes each Date as { __deserializedType: 'Date', __isSerializedType: true, value: '…' }. create also parses incoming data with the reviver before validating it. updateById does neither. It merges the patch into the stored, still-serialized data and validates that against the instrument's schema, so z.date() receives the wrapper object and fails. The caller gets a 400 "Merged data does not match validation schema". Data cannot be corrected through this endpoint for any form with a date field. A date the caller sends in the patch is also a plain JSON string, so it fails the same check. If validation does pass (for example with z.coerce.date()), the result is written back without serializeData. The stored shape then differs from what create writes, and readers that revive dates get a string instead.
Where
apps/api/src/instrument-records/instrument-records.service.ts:455-476:
const updatedData = mergeWith(instrumentRecord.data, data, (updatedValue: unknown, sourceValue: unknown) => {
// ...
});
const parseResult = await instrument.validationSchema.safeParseAsync(updatedData);
if (!parseResult.success) {
throw new BadRequestException({
issues: parseResult.error.issues,
message: 'Merged data does not match validation schema'
});
}
return this.instrumentRecordModel.update({
data: {
// ...
data: parseResult.data
},Compare with create at :113 (instrument.validationSchema.safeParse(this.parseJson(rawData))) and :128 (data: this.serializeData(parseResult.data)).
Reproduce
- Create a record for a form whose schema is
z.object({ score: z.number(), visitDate: z.date() }). It is stored as{ score: 1, visitDate: { __deserializedType: 'Date', __isSerializedType: true, value: '2024-01-01T00:00:00.000Z' } }. PATCH /v1/instrument-records/<id>with{ "data": { "score": 2 } }.
Actual: 400, issues: [{ code: 'invalid_type', expected: 'date', path: ['visitDate'], message: 'Invalid input: expected date, received object' }]. Reproduced by running updateById against the real zod schema and replacer-serialized data.
Expected: 200, with score updated and visitDate unchanged, stored in the same serialized form create uses.
Tests
apps/api/src/instrument-records/__tests__/instrument-records.service.test.ts: it('should revive stored dates before validating a patch, so a record with a date field can be updated'). Use a real z.object({ score: z.number(), visitDate: z.date() }) schema and replacer-serialized stored data, and assert that the update call writes visitDate back in serialized form. For e2e, add a spec in testing/src/specs/ that creates a record for a form with a date field through the API, PATCHes another field, and expects 200.
Suggested fix
Revive both sides before merging (this.parseJson(instrumentRecord.data) and this.parseJson(data)), validate the merged result, and store this.serializeData(parseResult.data), mirroring create.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e).updateByIdhas not changed since this was filed and the cited lines still match. #1807 and #1660 edit other parts of the file, not this function.What the issue does not cover
DNP_HAPPINESS_QUESTIONNAIREdeclarescausesOfDissatisfaction: z.set(...)(index.ts:170). No record that stores that field can be patched: validation fails withexpected set, received object. No built-in instrument has a date field, so this is the one to use in the e2e test.valuearray then hits the Instrument record PATCH silently discards arrays supplied for existing array fields #1639 customizer (:456-458), which keeps the stored members. Reviving only the merged result would therefore return 200 and leave the set unchanged. Once revived, aSetorDateis not a plain object, so lodash assigns it whole and the customizer never sees its array. Checked against lodash-es 4.18.1, libjs 3.2.1 and the vendoredzod/v4: a newSetand a newDateboth validate and serialize back with the new values. Instrument record PATCH silently discards arrays supplied for existing array fields #1639 is still needed for plain arrays.reviveronly revives the wrapper. After the fix, a date in the patch must still be sent as{ __deserializedType: 'Date', __isSerializedType: true, value }, as oncreate, and a plain ISO string is still rejected byz.date(). Nothing documents this, since$UpdateInstrumentRecordDatatypes the values asz.any(). The only in-repo caller isodc-cli instrument-records update(cli/odc-cli:383-386). Name the format in its--datahelp (:578) and in adescriptionon the@ApiOperationatinstrument-records.controller.ts:104.cli/AGENTS.mdasks for CLI changes to land with the API change.Plan
The suggested fix as written, in
updateByIdonly.computeMeasures(:472-474) then receives revived data, as it does increate(:126).Tests
describe('updateById')(instrument-records.service.test.ts:1073).useInstrumentstubssafeParseAsyncto return its input, so pass a real schema:useInstrument({ validationSchema: z.object({ ... }) })withzfromzod/v4. Add the case the issue names, plusshould replace a set field with the set the patch supplies: stored['MONEY'], patch['FRIENDS'], both as Set wrappers, and expect the written wrapper to hold only['FRIENDS']. That case fails if the revive moves after the merge.testing/src/specs/instrument-record-update.spec.tsthat Instrument record PATCH silently discards arrays supplied for existing array fields #1639 proposes, set up likerecord-file-deletion.spec.ts. As admin, POST aDNP_HAPPINESS_QUESTIONNAIRErecord withcausesOfDissatisfactionas a Set wrapper. It also needsisSatisfiedOverall: falseand areasonNotSatisfied, or the schema'ssuperRefinereturns 422. PATCH{ data: { personalLifeSatisfaction: 8 } }and expect 200, the wrapper unchanged andcomputedMeasures.overallLifeSatisfactionrecomputed.Landing
One PR with #1633 and #1639, in the order #1633, this, #1639. All three edit
updateByIdand the same test block. #1683 is the same class of bug inreact-core: serialized data reaches code that expects a realDateorSet.Triage note written with Claude Code. Code references point at
main@d86740e.