Repository navigation
Conversation
Bulk combined import checked each row in four places (its validator, a prepare step, an in-transaction revalidate, and the container writer), and the copies disagreed with the write: - A row naming an existing plate by name with an unknown collection barcode passed validation without an occupancy check, then failed on import, or landed in a plate whose barcode the row contradicted. - Two papers on one Sheet in different subjects failed validation but imported. - One plate named by name in one row and by barcode in another escaped the in-file position check. lib/specimen-registration owns Specimen registration (ADR 0010): registerSpecimens plans every entry, refuses with every error, or writes; checkSpecimenRegistration is the same write, rolled back. It finds or reuses the Specimen by (Source, type, date), resolves each Collection so every identifier given must name the same one, checks Grid positions, barcodes, and sublabels against the database and the rest of the request by resolved Collection, and applies unit defaults. Bulk combined import now finds or creates Subjects and hands the rest to the module; its validate route is the full_file import rolled back. createContainerForSpecimen wraps registerSpecimens, so every container write follows the same rules while the other paths' validators wait for their own slices. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
withRolledBackTransaction left BEGIN open across awaits on the one bun:sqlite connection. A request that wrote while a validate was open got "within a transaction" from its own BEGIN, ran inside the validate's transaction, and lost its writes when the validate rolled back, after it had already reported success. Two overlapping withWriteTransaction calls shared one transaction the same way. The open transaction now lives in an AsyncLocalStorage, and a lock per connection serializes transactions. A call from inside the same async context runs in a savepoint; a call from any other context waits for the lock and opens its own transaction. Nesting no longer depends on the SQLite error text. A nested withWriteTransaction that throws now rolls back its own writes, so a per_subject import run inside an open transaction drops the failed subject's Subject row too. ADR 0010 says how per_subject gets that. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The db README covered only withWriteTransaction. It now lists both helpers, how the per-connection lock and AsyncLocalStorage decide between waiting, a savepoint, and a new BEGIN, and the writes the lock does not cover: plain writes and Drizzle's sync transactions still land in whatever transaction is open, and are lost if it rolls back. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A full_file import whose write hit a constraint threw ConflictError with the first message only, while its 400 carried details.errors. ConflictError now takes details and the route handler returns them, so the 409 lists every error too, and /subjects/with-specimens does the same. failInsertsWithUniqueViolation lets a test reach that path: a trigger makes the insert fail as if a concurrent request took the row. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
createContainerForSpecimen turned registration's 409 into
{ success: false }, so its callers reported a constraint hit as their
own failure: add-container and POST /specimens replied 400, and
derivations, control batches and /specimens/bulk threw a plain Error
(500). It now throws ConflictError with the errors, and POST /specimens
lets it through instead of wrapping it in a ValidationError.
Each route has a test that makes the insert fail as a concurrent write
would and expects 409.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… asked for it. writeRegistration reported a constraint hit while inserting a planned Collection against entry 0, because its current-row marker only moved once specimens were written. Each planned Collection now records the first container that needs it (directly, or as its Sheet's box or bag), and a 409 on that insert names that entry and container. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r name. A row whose collection id found a plate named differently from the row's name was told the plate "with this barcode" had that name. The message now names the identifier that found it: the id, or the barcode. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s own. A new plate given two locations failed only within one registerSpecimens call. Control batches and /specimens/bulk register one container per call, so the second call found the plate the first had created and ignored its location. The planner now checks a given location against the stored Collection as well as a planned one, for plates, boxes, and bags, and ADR 0010 states the rule. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… registration. /specimens/bulk validated with registration-prepare, which resolved collections through container-write-placement: a barcode that missed fell back to the name, and two papers on one Sheet were rejected. Its write already went through registerSpecimens, so validate passed rows the import then refused, and refused rows the import accepted. The route now resolves each row's Source and hands the rows to registerSpecimens in one call; validate is checkSpecimenRegistration on the same entries. That leaves registration's collections.ts as the only placement resolver, so container-write-placement, registration-prepare, and container-placement-validation are deleted. ADR 0006 points at the new resolver and ADR 0010 moves /specimens/bulk and /subjects/with-specimens into slice 1. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
checkSpecimenRegistration returned the Collections a registration would create, which no caller read. It now returns the errors alone. checkSpecimenRegistration and the controlBatchId source stay: the /specimens/bulk validate route calls the first and passes control rows through the second. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lass, and lookups. - BulkCombinedContainerInput was RegistrationContainer under a second name; bulk combined import uses RegistrationContainer. - BulkCombinedRejected copied SpecimenRegistrationError. Both now extend RefusedWithErrors, which carries the status and the error list keyed the way each caller keys its input. - CollectionPlanner looked a plate up by id, barcode, and name with three copies of one query, and a box or bag with two. storedGrid and storedParent each do it once. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… in one place. The step from a container type to its plate or box type, or to its table, was written out five times across plan.ts, collections.ts, and write.ts. tables.ts now holds those maps, and the planner and writer look types up there. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…message matching.
- Request claims were template strings built at each call site. A Claim
union (barcode, position, sublabel) now has one key builder.
- entrySpecimen marked an entry with errors as -1; it is now undefined,
and findSpecimen takes the Source union instead of falling back to
controlBatchId -1.
- Bulk combined import reworded a missing position by matching
"Position (well) is required" in the message. validateContainerWriteFields
now returns the field each error belongs to, and its position message
("Position is required for micronix tubes (e.g. A01).") serves every
caller, so the rewording is gone.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
only(rows, what) returned the row an INSERT … RETURNING gave back, or threw. The name now says that. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…port say "your file". Registration errors named CSV columns (plate_name, box_name, sheet_name) and said a value repeated "in your file", though add-container, POST /specimens, /subjects/with-specimens, and the specimen entry form send JSON. The module now says "in this request" and describes missing names without column names. RegistrationOptions.inputName lets a caller name its input; the bulk combined import, which takes a CSV, passes "your file". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t the difference. The parity fixtures ran only full_file imports. Each fixture now also runs per_subject and checks the weaker property that mode allows: validate passes exactly when every subject imports, and every subject validate flags is one the import rejects. One more test pins the case where they differ: a subject that relies on a plate an earlier, failing subject would create is rejected by the import but not flagged by validate. validateBulkCombinedPayload and ADR 0010 describe this. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tion # Conflicts: # packages/api/src/db/README.md # packages/api/src/lib/bulk-combined-import.ts # packages/api/src/lib/container-creation.ts # packages/api/src/lib/container-write-placement.ts # packages/api/src/lib/registration-orchestrator.ts
Validate always checked the file as one full_file import, so in per_subject mode it could pass a subject the import rejects: one that relies on a plate an earlier, failing subject would have created. Validate now takes the payload's atomicMode and, for per_subject, runs each subject in its own savepoint inside the rolled-back transaction, as the import does, so it flags exactly the subjects the import rejects. The parity fixtures check that for per_subject, with the failed-creator case and the two-locations case. ADR 0010 says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ADR 0006 still named createContainerForSpecimen after resolveContainerPlacement as the single write executor. It now carries an "Amended by ADR 0010" note there and lists ADR 0010 in its related ADRs, and ADR 0010 gains a Related ADRs section saying what it changes in 0006. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Container writes used to let a collection id win over a name or barcode beside it, and fell back from an unknown barcode to the name. Since registration they must all name the same Collection. ADR 0010's consequences now say so, for the createContainerForSpecimen callers too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…se them. The registration and bulk combined tests inserted specimen_type_container_type and container_type_unit rows by hand, which the test README says not to do. createTestSpecimenTypeContainerType and createTestContainerTypeUnit replace those inserts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…use the collection type unions. validateContainerWriteFields repeated the "position is required" check for each grid container type; hasPosition and positionRequired now hold it once. CreatedCollection's type is built from GridCollectionType and ParentCollectionType instead of restating them in RegisteredCollectionType, which nothing else used. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the entry-to-specimen map. The plan unpacked the Source union into a nullable studySubjectId and controlBatchId pair and keyed Specimen identity with an inline string. A planned Specimen now keeps its RegistrationSource until the insert, and SpecimenIdentity with specimenIdentityKey states the identity rule the way Claim and claimKey state the request claims. entrySpecimen is renamed specimenIndexOfEntry and useSpecimen, which read like a React hook, specimenIndexFor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
validateContainerWriteFields read a barcode off paper inputs with Reflect.get and rejected it, though every route parses containers with the contract schemas, which reject a barcode on paper, and the CSV mapper rejects the column (ADR 0006). A route test pins that POST /specimens/:id/containers refuses it with 400; the unit test that bypassed the type to reach the module's copy is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tion # Conflicts: # packages/api/src/db/README.md # packages/api/src/lib/error-handler.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Stacked on #89. First slice of ADR 0010: Specimen registration goes behind one module, and validation becomes the write itself, rolled back.
Rules now enforced in one place:
createContainerForSpecimennow wrapsregisterSpecimens, so derivations, control batches,POST /specimensand add-container already write with these rules. Their separate validators are replaced in slices 2–4.Evidence
All three mismatches were reproduced against the old code before this change.
P1(barcodeP1-BC, tube at A01) and a row{ name: 'P1', barcode: 'UNKNOWN', position: 'A01' }→ validate{ valid: true }, import fails "Position A01 is already used". At A02 the import put the tube inP1.After: both report
Micronix plate 'P1' has barcode 'P1-BC', not 'UNKNOWN'.After: both accept; a repeated sublabel on that sheet is rejected by both.
validate(payload).valid === importSucceeds(payload), and each matches its expected validity.bun run ci:verifypasses locally (API: 1069 pass, 0 fail).Review follow-ups
per_subjectmode too: validate takes the payload'satomicModeand runs each subject in its own savepoint inside the rolled-back transaction, so a subject that depends on a collection a failed subject would have created is flagged by both. The parity fixtures cover both modes.POST /specimens,/specimens/bulk, derivations, control batches) returns 409 with every error, not 400 or 500./specimens/bulkvalidates and registers through Specimen registration, so its validate and import agree.withRolledBackTransactionand the lock are documented in the db README.per_subjectwording matches the code.-1sentinels; errors are worded for any caller; the paper barcode check is left to the contract schema; new test factories for allowed container types and units.bun run ci:verifypasses: API 1144, web 948, contract 86.Merge Danger
Door: two-way
No schema or wire-shape change. The validate and import response bodies keep their shapes. A full_file import failure now also carries
details.errorswith every problem.Blast Radius: every container write
Every path that creates a Container goes through the new module. Inputs that used to pass now fail:
idwon and anameorbarcodebeside it was ignored. A form that sends anidwith a stale name or barcode is now refused with 400.Messages for some failures changed wording. Each failure now returns every error in one response.
🤖 Generated with Claude Code