fix(api): keep an uploaded file instrument record pending - #1422
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes a production defect in the API bulk-upload path (InstrumentRecordsService.upload) where newly created instrument records were persisted without a pending field, causing them to be missed by any query filtering on pending (unless it used the legacy compatibility OR clause). The change aligns upload with the existing create behavior so newly uploaded records always have an explicit pending value.
Changes:
- Set
pendingexplicitly during bulk upload, matching thecreatelogic (pending: instrument.kind === 'FILE'). - Add a unit test to pin the FILE-instrument branch (FILE uploads remain
pending: trueso files can be attached later).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| apps/api/src/instrument-records/instrument-records.service.ts | Ensures bulk-uploaded records always persist an explicit pending value, consistent with the single-record create path. |
| apps/api/src/instrument-records/tests/instrument-records.service.spec.ts | Adds coverage to ensure FILE instrument uploads produce pending: true (and existing pending assertion for FORM remains satisfied). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
This branch conflicts with CI is green, but GitHub reports For context on the conflict: Please rebase or merge |
|
Suggested model: Opus 5 Sizing the remaining work on this PR for whoever picks it up, human or agent. The conflict resolution here isn't mechanical: |
joshunrau
left a comment
There was a problem hiding this comment.
Requesting changes so this tracks as needing author action.
Blocking: conflicts with main. CI is green. Details in my earlier comment.
Worth reading 7e0ef1a0f and 722f0a0b0 before resolving — main addressed the same pending visibility problem from the other direction, treating missing and false alike via an OR filter on the find side rather than writing the field on create. So the resolution here is less a merge than a question of whether this PR's approach is still the one you want.
No content review has been done on this branch yet — it's gated on the conflict being cleared.
`upload` writes `pending: false` for every record regardless of instrument kind, while `create` writes `pending: instrument.kind === 'FILE'`. A file instrument record exists before its file does -- `FilesService` sets `pending: false` only once the upload lands, and refuses to attach a file to a record that is not pending -- so a bulk-uploaded file record is born settled and its files can never be attached. `upload` does not reject file instruments the way it rejects series instruments; verified live, a file instrument upload returns 201. Mirror `create` so the two paths agree. Scope note: this branch originally also introduced writing `pending` at all, since `upload` omitted the field entirely and only the find-side compatibility clause kept those records visible. main has since written the field independently in 573f307, hardcoded to false. What remains here is only the FILE case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ZEJLwa7jwD8sKzcE4PfSc
744fd53 to
1ae1043
Compare
joshunrau
left a comment
There was a problem hiding this comment.
Thanks — the create/upload divergence you found is real, and the diff is clean: lint passes, the
api suite is green, and removing the stale comment is correct since 573f307c9 did make upload
write the field.
The question this raised — should upload accept file instruments at all? — has now been answered,
and unfortunately it goes against this diff. Joshua's decision is that upload should reject
file instruments with UnprocessableEntityException, alongside the SERIES rejection already there,
rather than write them as pending. The reasoning is the one your own investigation surfaced: the
bulk-upload page lists only FORM instruments, createUploadTemplateCSV casts to a form instrument,
and $UploadInstrumentRecordsData cannot carry a file — so a record created on that path can never
be completed, whichever way pending is set. Better to fail loudly than to write something
incomplete.
That makes pending: instrument.kind === 'FILE' dead code, so this PR is being closed in favour of a
guard rather than reworked. Concretely, what replaces it:
- In
apps/api/src/instrument-records/instrument-records.service.ts:379-391, reject
instrument.kind === 'FILE'withUnprocessableEntityExceptionnext to the existing SERIES
rejection. - Replace the two
pendingunit tests with one asserting that rejection. Please do carry over the
deletion of the stale spec comment — you were right that573f307c9made it contradict the code,
and that should not be lost with the rest of the diff. - Add the end-to-end test the root
CLAUDE.mdasks for. This is easier now than it was for a
pending record: thetesting/apifixture can call the upload endpoint with a file instrument
and assert the 422, with no object storage needed.
Worth knowing: #1427 sets the same expression in the same method and is being reworked to this same
decision, so coordinate which of the two lands the guard.
If you hand this to Claude Code, Sonnet 5 is the right size — one guard, one unit test and one
e2e assertion, in files these items name outright, with the design decision already made.
Reviewed at commit 1ae1043.
|
superseded by #1427 |
PR #1422 — fix(api): keep an uploaded file instrument record pendingCLOSE · confidence: high · gdevenyi · +19/-3, 2 files What it does
Why this verdictThe inconsistency the PR names is real and the diff is minimal, clean and tested — but the decision Settled (2026-07-28)
Action items
Suggested model: Sonnet 5 — one guard, one unit test and one e2e assertion, all in files the Evidenceok: not checked: no live instance, so the author's runtime observations (file-instrument upload returning Reply to authorThanks — the The question this raised — should That makes
Worth knowing: #1427 sets the same expression in the same method and is being reworked to this same If you hand this to Claude Code, Sonnet 5 is the right size — one guard, one unit test and one Reviewed at commit 1ae1043. |
`SessionsService` gains a `createMany` that resolves subjects, the user
and the group once for the whole batch, associates subjects with the group
in a single write, and inserts every session in one call. Session ids are
generated up front so the results come back in input order and a caller
can pair each session with the entry that produced it. `create` now
delegates to it, so there is one implementation rather than two.
`upload` uses it, and validates every record before anything is written so
a malformed record in the middle of a batch no longer creates sessions
that then have to be rolled back. Its response is scoped to the sessions
this upload created rather than returning every record in the group for
that instrument.
Measured over HTTP against a live instance, uploading 25 records:
main 85-110 records returned, 51-66 KB, ~242-276 mongo ops
this branch 25 records returned, 14.8 KB, ~95 mongo ops
The record count on main grows on every upload; here it stays at 25.
Fixes a bug along the way. `create` set a session's groupId only when the
subject was not already a member of that group, so every visit after a
subject's first produced a session with no groupId. A GROUP_MANAGER's
Session rule is { groupId: { in: [...] } }, which means those sessions
were invisible to them, and uncounted by any group-scoped query including
the dashboard summary.
From review on this branch:
- `upload` now rejects file instruments with UnprocessableEntityException,
alongside the existing series rejection, rather than writing them
pending. The bulk payload cannot carry a file, this path never attaches
one, and the client discards the response ids, so such a record could
only ever be incomplete. This supersedes #1422, which set `pending` on
that record instead.
- The batched group association moved behind `SubjectsService`, where the
rest of the subject writes live, replacing `addGroupForSubject` -- which
this change had left with no callers -- rather than reaching into
`prismaClient.subject` from the sessions service.
- `CreateManySessionsData` is derived from `CreateSessionData` instead of
restating it, so the two cannot drift.
- `SubjectsService.createMany` names the fields it writes rather than
spreading the caller's object; `demo.service.ts` hands it a full Prisma
row.
- Dropped two comments that described what the code used to do.
Every claim above is pinned by a test, each verified by mutation:
`SessionsService` had no test file at all, so this adds one, and the
end-to-end suite now covers the groupId fix -- reinstating the old
behaviour returns one of the two sessions instead of both.
Refs #1414
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZEJLwa7jwD8sKzcE4PfSc
|
Rebased onto The FILE-rejection guard has landed in #1427 as you directed, so this branch's Nothing here needs re-reviewing — #1427 is where the decision is implemented. |
`SessionsService` gains a `createMany` that resolves subjects, the user
and the group once for the whole batch, associates subjects with the group
in a single write, and inserts every session in one call. Session ids are
generated up front so the results come back in input order and a caller
can pair each session with the entry that produced it. `create` now
delegates to it, so there is one implementation rather than two.
`upload` uses it, and validates every record before anything is written so
a malformed record in the middle of a batch no longer creates sessions
that then have to be rolled back. Its response is scoped to the sessions
this upload created rather than returning every record in the group for
that instrument.
Measured over HTTP against a live instance, uploading 25 records:
main 85-110 records returned, 51-66 KB, ~242-276 mongo ops
this branch 25 records returned, 14.8 KB, ~95 mongo ops
The record count on main grows on every upload; here it stays at 25.
Fixes a bug along the way. `create` set a session's groupId only when the
subject was not already a member of that group, so every visit after a
subject's first produced a session with no groupId. A GROUP_MANAGER's
Session rule is { groupId: { in: [...] } }, which means those sessions
were invisible to them, and uncounted by any group-scoped query including
the dashboard summary.
From review on this branch:
- `upload` now rejects file instruments with UnprocessableEntityException,
alongside the existing series rejection, rather than writing them
pending. The bulk payload cannot carry a file, this path never attaches
one, and the client discards the response ids, so such a record could
only ever be incomplete. This supersedes #1422, which set `pending` on
that record instead.
- The batched group association moved behind `SubjectsService`, where the
rest of the subject writes live, replacing `addGroupForSubject` -- which
this change had left with no callers -- rather than reaching into
`prismaClient.subject` from the sessions service.
- `CreateManySessionsData` is derived from `CreateSessionData` instead of
restating it, so the two cannot drift.
- `SubjectsService.createMany` names the fields it writes rather than
spreading the caller's object; `demo.service.ts` hands it a full Prisma
row.
- Dropped two comments that described what the code used to do.
Every claim above is pinned by a test, each verified by mutation:
`SessionsService` had no test file at all, so this adds one, and the
end-to-end suite now covers the groupId fix -- reinstating the old
behaviour returns one of the two sessions instead of both.
Refs #1414
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZEJLwa7jwD8sKzcE4PfSc
This is the test failure I've been flagging as pre-existing in #1418, #1419, #1420 and #1421. It turns out to be a real defect, not a stale test.
What happened
InstrumentRecordsService.uploadbuilds its records without apendingfield at all, so every bulk-uploaded record is persisted with the field absent rather thanfalse.PR #1404 ("fix pending tag issue which cause old data not to be visible") fixed the resulting invisibility by teaching
findto match records where the field is missing:and in the same commit added a test asserting that
uploadsetspending— but never made that change toupload. The test has been red onmainsince that PR merged (8998d811d).So the test was correct and expressed the intent; the production half of the fix was simply missing.
Why it still matters
The comment on the compatibility clause says it exists for "records created before the file instrument feature". But because
uploadstill omits the field, that clause is not a legacy shim — it is load-bearing for newly created data, and any query that filters onpendingthe natural way silently misses every uploaded record.Confirmed against a live instance, before the fix:
Why this mirrors
createinstead of hardcodingfalseMy first cut was
pending: false, which is literally what the test asserts. Then I checked whetheruploadcan receive a file instrument — it does not reject them the way it rejects series instruments — and it can:Hardcoding
falsewould therefore mark a record with no attached files as complete, andFilesService.getAssociationswould then reject any attempt to attach them withUpload already completed. So this uses the same expressioncreatealready uses one function above:which satisfies the test (its fixture is a
FORM) and keeps the two creation paths consistent.Verified live, after the fix
No regression for existing data: records created before this change are still matched by the
isSet: falseclause, which stays exactly as it is.Tests
The existing test now passes. Added one covering the file-instrument branch, so the
create/uploadconsistency is pinned rather than incidental.vitest runacross the whole workspace is now fully green — 37/37 files, 254 passed, 1 skipped, 0 failed. It has had this one failure throughout.tsc --noEmitonapps/api— cleaneslint src— cleanprettier --check— cleanNote
While verifying this I re-confirmed the separate issue filed as #1414:
upload's return value isfindMany({ groupId, instrumentId }), so uploading one record returned all 8 records for that instrument. Not touched here.Rebased onto
main— the scope is now much narrower than this title suggestsRebased onto
26ec89168; conflict resolved, CI green.You were right that this needed deciding rather than replaying, but the history is not quite
"
maintook the opposite approach". Reconstructing it:722f0a0b0reframed the create-side test,uploadgenuinely did not writepending— Iconfirmed at
722f0a0b0^that onlycreatedid. So that reasoning was correct at the time.573f307c9("resolve series regressions after main merge")added
pending: falsetoupload. It reachedmainafter the test was reframed, so nothingre-asserted it.
Net effect on today's
main:uploadwritespending: falseunconditionally, and no test covers it.So the "write the field at all" half of this PR has already arrived by another route. What remains
is only the FILE case, which is still a real defect:
createuploadonmainfalsefalsefalsetruefalse❌trueFilesServiceclearspendingonce the upload lands, and refuses to attach a file to a record thatis not pending — so a bulk-uploaded file record is born settled and its files can never be attached.
uploaddoes not reject file instruments the way it rejects series instruments; verified live, afile instrument upload returns 201.
The diff is now one expression plus its tests, and the title and commit message were rewritten to say
so. The comment
722f0a0b0left on the create-side test is corrected, since on this branch itasserts the opposite of what the code does, and the FORM case gets its assertion back.
🤖 Generated with Claude Code
https://claude.ai/code/session_014ZEJLwa7jwD8sKzcE4PfSc