Skip to content

Instrument record PATCH rejects every update to a record with a date field, because stored dates are never revived #1723

Open

Description

@joshunrau

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

  1. 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' } }.
  2. 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

  1. added
    BugType: existing behavior is wrong
    Difficulty: LowIsolated change, about 2 hours or less
    Area: APIapps/api (NestJS backend, Prisma models, CASL permissions)
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). updateById has 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

    • A built-in instrument hits this. DNP_HAPPINESS_QUESTIONNAIRE declares causesOfDissatisfaction: z.set(...) (index.ts:170). No record that stores that field can be patched: validation fails with expected set, received object. No built-in instrument has a date field, so this is the one to use in the e2e test.
    • Revive before the merge, not after it. While both sides are still wrappers, a Set in the patch is merged key by key into the stored wrapper. Its value array 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, a Set or Date is 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 vendored zod/v4: a new Set and a new Date both 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.
    • reviver only revives the wrapper. After the fix, a date in the patch must still be sent as { __deserializedType: 'Date', __isSerializedType: true, value }, as on create, and a plain ISO string is still rejected by z.date(). Nothing documents this, since $UpdateInstrumentRecordData types the values as z.any(). The only in-repo caller is odc-cli instrument-records update (cli/odc-cli:383-386). Name the format in its --data help (:578) and in a description on the @ApiOperation at instrument-records.controller.ts:104. cli/AGENTS.md asks for CLI changes to land with the API change.

    Plan

    The suggested fix as written, in updateById only. computeMeasures (:472-474) then receives revived data, as it does in create (:126).

    Tests

    • Unit, in describe('updateById') (instrument-records.service.test.ts:1073). useInstrument stubs safeParseAsync to return its input, so pass a real schema: useInstrument({ validationSchema: z.object({ ... }) }) with z from zod/v4. Add the case the issue names, plus should 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.
    • e2e: the API-only testing/src/specs/instrument-record-update.spec.ts that Instrument record PATCH silently discards arrays supplied for existing array fields #1639 proposes, set up like record-file-deletion.spec.ts. As admin, POST a DNP_HAPPINESS_QUESTIONNAIRE record with causesOfDissatisfaction as a Set wrapper. It also needs isSatisfiedOverall: false and a reasonNotSatisfied, or the schema's superRefine returns 422. PATCH { data: { personalLifeSatisfaction: 8 } } and expect 200, the wrapper unchanged and computedMeasures.overallLifeSatisfaction recomputed.

    Landing

    One PR with #1633 and #1639, in the order #1633, this, #1639. All three edit updateById and the same test block. #1683 is the same class of bug in react-core: serialized data reaches code that expects a real Date or Set.

    Triage note written with Claude Code. Code references point at main @ d86740e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: APIapps/api (NestJS backend, Prisma models, CASL permissions)BugType: existing behavior is wrongDifficulty: LowIsolated change, about 2 hours or lessPriority: MediumReal value; schedule normally

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions