Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-reports): infer export columns from every row, not the first 50 - #11840
Conversation
…st 50 `pickFields()` sampled `rows.slice(0, 50)` while the projection it produced was applied to ALL rows. With no explicit `query.fields`, any key whose first occurrence fell at row 51+ was absent from the header and dropped from every row that carried it — in a well-formed export of uniform arity, with nothing marking a column as inferred rather than declared. Sparse columns are the normal shape of report output, and the sampled prefix is the query's first page in its own `orderBy`, so for a report sorted by status or created date the sample correlated with exactly the column it dropped. Affects `renderCsv` (also `renderReport`'s `default:` branch) and `renderHtmlTable`; `renderJson` was never affected. Route: scan every row, rather than projecting from the saved report's declared column set — `ReportQuery.fields` is optional and `renderReport` is exported with no access to a saved report at all, so the declared-set route would need a new declared surface, which this card does not carry. Cost is bounded twice over: the row array is already capped by `Math.min(query.limit, maxRows)`, and measured, the added pass is ~30% of the O(rows x cols) pass both renderers already make over the same array (5000x20: +4.2ms against 12.9ms of render). First-seen column order is preserved and late columns append, so an export that was already correct is byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
📓 Docs Drift Check1 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 — 2 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 a759d90db7dd8741cf015b53de9a8cb49deddedb && git checkout a759d90db7dd8741cf015b53de9a8cb49deddedb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f7eff23ed4b19933e0543b2997185212bc0a7761 0a163296c76b0a5f486222c2ecc0f55c3020419c && git checkout -B drift-repro f7eff23ed4b19933e0543b2997185212bc0a7761 && git merge --no-ff 0a163296c76b0a5f486222c2ecc0f55c3020419c
node scripts/docs-audit/affected-docs.mjs --json f7eff23ed4b19933e0543b2997185212bc0a7761 |
os-sam
commented
Aug 24, 2026
PM review — PASS. Every load-bearing claim re-measured on the merge-base, not inherited
Diff shape. 3 files: the changeset, the test, and one 20-line hunk in ⭐ Clause-② — NO, judged from the live gate text, and not on the reason it would be tempting to giveRe-read live on
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11774
The defect
pickFields()inpackages/plugins/plugin-reports/src/report-service.tsinferred an export's columns fromrows.slice(0, 50), while the projection those columns drive was applied to all rows. With no explicitquery.fields, any key whose first occurrence fell at row 51 or later was absent from the header and dropped from every row that carried it.The export gave no signal: well-formed CSV, uniform arity across rows, nothing marking a column as inferred rather than declared. A recipient of a scheduled report attachment could not tell. And the sampled prefix is not a random sample — it is the query's first page in its own
orderBy, so for a report sorted by status or created date it correlated with exactly the sparse column it dropped. Sparse columns are the normal shape of report output: an optional field, a formula only some records satisfy, a lookup that resolves for a subset.Both affected renderers are fixed —
renderCsv(which is alsorenderReport'sdefault:branch) andrenderHtmlTable.renderJsonemits rows as-is and was never affected.Route taken, and why
Two routes restore the same stated contract ("the export carries every field present in the result set"). I took scan every row, not project from the saved report's declared column set:
ReportQuery.fieldsis optional inpackages/spec/src/contracts/report-service.ts. For a saved report that declares none there is no declared column set to project from, so that route would have to either makefieldsrequired or derive columns from the object's schema through a newReportEnginecapability — a new declared surface either way, which this card does not carry (triage's premise-first stop). Route rejected on that ground rather than on cost.renderReportis exported from the package and takes(rows, format, fields?)with no access to a saved report at all, so the declared-set route cannot reach that surface even in principle.Cost, measured
The card names the real concern: scanning every row is O(rows × keys) on the export path. Measured (
node, same shapes, 100 iters, the sparse key on the last row):renderCsvbody passThe cost is bounded twice over. The row array reaching the renderer is already capped by
Math.min(query.limit ?? 1000, maxRows)(maxRowsdefaults to 5000), and both renderers already make an O(rows × cols) pass over that same array. Inference is a stable ~30 % of the render it feeds across every shape measured — a fraction of work the export path already does unconditionally, not a new order of magnitude. At the service's own default cap that is +4.2 ms.First-seen column order is preserved and late-appearing columns append, so an export that was already correct is byte-identical.
Pin
Written test-first, with the expected failure signature predicted in writing before the first run — and the run matched it exactly (
3 failed | 76 passed,AssertionError: expected [ 'id', 'status' ] to include 'escalation_note'on all three).Fixture: 60 rows sharing
id+status, with only row 55 carryingescalation_note— a key first appearing past the old sample boundary. Three tests, covering both renderers and the end-to-end service path:renderReport(..., 'csv')— the named late column reaches the header, and'breached SLA'arrives in row 55 under that column's index, and nowhere else.renderReport(..., 'html_table')— the same two facts against parsed<th>/<td>cells.ReportService.run()over a saved report declaring noquery.fields— the scheduled-attachment path end to end.Identities are pinned, not counts: each asserts the named column and its value, plus
toEqual(['id', 'status', 'escalation_note'])for order. That mattered in practice — the order assertion caught a bug in the test's own<th>extraction (<th[^>]*>also matches<thead>), which atoContain-only pin would have hidden.toHaveLength(3)would have missed it too.The pre-existing
'auto-detects fields from first 50 rows when none specified'assertedtoMatch(/a|b/)— an alternation that passes either way. It is renamed and strengthened totoEqual(['a', 'b']); it was never part of the red.Verification
Test Files 5 passed (5)·Tests 79 passed (79)—pnpm --filter @objectstack/plugin-reports test, at0a163296c7.tsc --noEmitclean, exit 0 —pnpm --filter @objectstack/plugin-reports typecheck.Gates derived at
0a163296c7on a clean tree vianode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no path arguments). 17 of 18 green with exit codes captured before any pipe. The 18th,check:type-check-debt, refused to measure on this worktree (its documented unbuilt-closure refusal — "41 workspace dependencies ... have no built type entry point on disk"); it is recorded as NOT MEASURED, not as a pass. See the report comment on #11774 for the full per-gate verdict lines and the narrowing evidence.Contract review
Clause ② assessed against the diff, not inherited: NO. The whole source diff is one hunk inside the module-private
pickFields(referenced nowhere outside the file); the file's exported symbol list is byte-identical base vs head (7 exports, same signatures);src/index.tsandpackages/spec/are untouched. Nothing here accepts or rejects —pickFieldshas no refusal path, no error code, no schema — so no accept/reject surface moves and no public surface widens. What changes is that the export now carries columns it was already contractually required to carry.Generated by Claude Code
Generated by Claude Code