Repository navigation
fix(rest)!: format=json honours an explicit ?fields= - #24206
Conversation
The export route declares `fields=a,b,c` for every format. CSV and XLSX wrote exactly the listed columns; the JSON arm wrote each row as the read returned it. With an explicit list, each JSON row now carries exactly the named keys, in list order, read from the same resolved `fields` the other two arms write. A named key the read did not return stays absent. format=json without ?fields= is unchanged. Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb Co-Authored-By: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 18 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4d6443fc78666b7382e8179bb6eb8a6ad397d264 && git checkout 4d6443fc78666b7382e8179bb6eb8a6ad397d264
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f963d8e2b32b11be6d8d50d3134e4f3ee888e16a 939c8ac1a7fa3f14259029ef662f6d82c2f4ad5d && git checkout -B drift-repro f963d8e2b32b11be6d8d50d3134e4f3ee888e16a && git merge --no-ff 939c8ac1a7fa3f14259029ef662f6d82c2f4ad5d
node scripts/docs-audit/affected-docs.mjs --json f963d8e2b32b11be6d8d50d3134e4f3ee888e16a |
The seat decided the no-fields fork as D: `QuerySchema.fields` absent means no projection, so json without `fields` writes each row as the read returns it, `id` included. One pin through the real route asserts each row's key set equals the observed read's, and that the read carries a key the csv default drops, so a cut to that default reddens it. Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb Co-Authored-By: Claude <noreply@anthropic.com>
Contract reviewServed-tier: PR #24206 on card #24183, narrowing face · Inputs: the card body and all five comments (triage 6236168153, claim 6237380493, reports 6238180833 and 6238602315, seat decision 6238218754); the PR body, its file list (3 files, +266/−3) and the net diff against Check-runs on the head, read at 2026-10-17T18:43Z: 47 check-runs, 39 success, 8 skipped (paths-filtered: Build Docs, Console Pin Gate, the Dogfood pair, Temporal Conformance, Packed-tarball smoke, one Auto Label and one Check PR Size), 0 failure, 0 in progress. All five required contexts are ① Derived judgmentsEvery accept-set or served-set change the diff implies, each judged:
② Semver level
③ Boundary flagsDev flags (reports 6238180833 and 6238602315) and open questions, each answered:
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 — ACCEPT · PR #24206 at
|
Fixes #24183
Clause-②: no (narrowing)
BREAKING (a served-set narrowing at a REST door): with
?fields=, each row ofGET /api/v1/data/:object/export?format=jsonnow carries exactly the named keys. It ships asminorunder the launch-window convention. The changeset carries the sameClause-②line and one ADR-0087 disposition (not-required (no-migration-prescription)). This narrowing owes oneCONTRACT_REVIEW_TIERreview of record before the queue.Why
Fixes: this PR now lands both halves of the card. The explicit-fieldshalf was the first round. The no-fieldshalf was a fork: the claim's premise failed when measured (see "The no-fieldsfork" below), and the seat decided D in comment 6238218754. JSON withoutfieldswrites each row as the read returns it, which is whatQuerySchema.fieldsdeclares (optional, "Fields to retrieve": absent means no projection). Patch round 1 pins that answer through the real route,idincluded, and the route doc states it. The fork section below is kept as the measurement record. (This paragraph and the first line were edited by the seat; the dev writes the body once.)What changed
packages/rest/src/data-action-routes.ts, JSON arm of the export handler: when?fields=names columns, each row is cut to the resolvedfieldslist (pickExportKeys), the same list the CSV and XLSX arms write.formatRowForJsonthen formats the row as before. Keys come out in list order. A named key the read did not return is left out of the row. It is not written asnull.fieldsExplicitrecords that the caller named the columns. Nothing after it narrows or reorders an explicit list: the hidden-field filter, the FLS projection and the masked-row fallback all run only on the schema-derived default. So the JSON arm reads the one resolved column list and does not define a second one.fields=now says what each format does with it, and that JSON withoutfieldswrites each row as the read returns it.formatRowForJsonandexport-format.ts,stripSearchCompanionFromRead,packages/objectql,packages/specand objectui.format=jsonwithoutfieldsis byte-for-byte the same as before.Does the CSV arm always write some extra key? Measured: no
?format=csv&fields=titlereturns the single columnTitle, both on the showcase and in the unit pin. So the JSON projection mirrors nothing beyond the named keys.Reach, re-measured on
examples/app-showcaseSetup: a
pnpm dev -- --freshboot of this worktree, signed in as the seeded admin. The shipped admin sets carry noallowExport, so I saved an environment permission set grantingallowReadandallowExportonshowcase_taskand assigned it to the admin.GET /api/v1/data/showcase_task/export?format=json&fields=title, 10 rows:8a88901be): 25 keys per row:id,created_at,updated_at,organization_id,created_by,updated_by,owner_id,owning_business_unit_id,title,project,assignee,status,priority,estimate_hours,progress,done,due_date,start_date,end_date,location,cover,labels,notes,sync_status,sync_error.{ "title": … }.After the fix,
?format=json&fields=status,title,id,organization_id,__searchreturns rows that carrystatus,title,idandorganization_id, in that order.__searchis absent because the read strips it. The list door answers the same way: in the unit harness,GET /data/note?$select=title,__searchreturns rows withtitleonly.Unchanged after the fix: the no-
fieldsJSON key set, and the CSV header with and withoutfields=title. I compared shapes, because each fresh boot seeds new ids.The no-
fieldsfork: the claim's premise, measured, failsThe claim's premise was that the default set (schema fields minus
hidden, ∩ FLS-readable) containsidand every key the JSON re-import reads back. It does not containid.showcase_taskdoes not declareid, and the registry injects noidfield, soidis not in the schema the default reads from.fieldsJSON today: the 25 keys listed above.fieldsheader label for label. They arecreated_by,updated_at,updated_by,owner_id,title,project,assignee,status,priority,estimate_hours,progress,done,due_date,start_date,end_date,created_at,location,cover,labels,notes,sync_status,sync_error.id;organization_id, which is hidden and carriesDefault Organization;owning_business_unit_id, which is hidden and null.The import runner reads
idback: it keeps a supplied id on insert and matches on it for update and upsert. I re-imported the export throughPOST /api/v1/data/showcase_task/importwithformat: jsonanddryRun: true:writeMode/matchFieldsfieldsJSON as served todayupsert/idupsert/idupdate/idThe options and a recommendation are in the
os-dev-reportcomment on #24183, for the seat to decide.Tests
packages/rest/src/export-json-fields.test.tsexercises the real export route: a realRestServeron a realObjectStackProtocolImplementationon a realObjectQL, with a better-sqlite3:memory:driver. The companion is declared by its real producer. The protocol'sfindDatais observed, so every pin shows the rows the read handed over next to what the route answered.?fields=titlewrites the singleTitlecolumn, while the read returnedid,title,internal_codeanddone.?fields=title: every row is exactly{ title }.?fields=done,internal_code,title,id: keys come out in list order, and values keep the export formatting (donebecomes是/否).?fields=title,__search: the stripped companion is absent from the row and is never written asnull. For the same list, CSV writes an emptySearch Indexcolumn.page=50, two reads): every row carries exactlyid.Ablation, through
scripts/ablation-replace.mjs. The anchor must hit, and the restore is checked against the blob. The test reachesdata-action-routes.tsthrough relative./rest-serverimports, so it runs fromsrc/and does not involvedist/.const row = fieldsExplicit ? pickExportKeys(rows[i], fields!) : rows[i];becameconst row = rows[i]; /* ablated-24183 */. The anchor count went from 1 to 0, and the blob frome78162e654a3to4c922008fbbb.expected [ [ 'id', 'created_at', …(4) ], …(1) ] to deeply equal …on the order pin. The CSV control stayed green, as it should, since the mutation leaves the CSV arm alone.e78162e654a3, which equals HEAD, andgit diff HEADis empty.Suite runs on
881f6797e:pnpm --filter @objectstack/rest test: exit 0, 293 files passed, 6019 tests passed, 326 skipped.pnpm --filter @objectstack/rest typecheck: exit 0. The new test file is in thetsconfig.test.jsonprogram (--listFiles: 1 hit).pnpm exec eslint --no-inline-configon the two changed source files: exit 0.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon881f6797e: 63 derived commands, all exit 0. The--ranreconciliation answers: 63 derived, 63 run, 0 NOT-MEASURED, every exit code recorded.test:repo(CI'sTest Core).Acceptance notes
?fields=, the export's default columns for CSV and XLSX do not carryidon an object that does not declare it. That was already true before fix(rest)!: a schema-derived export skips the fields declared hidden #24178: on the showcase, neither fix(rest)!: a schema-derived export skips the fields declared hidden #24178's before-header (atb0fe9fad4) nor the header at8a88901becarriesID. So a CSV export cannot re-import onto its own records byid. This matches the route's documented default (schema fields), so it is noted here and not filed. It bears on the fork above.$selecthere, not the CSV cell.Generated by Claude Code