Repository navigation
fix(objectql,metadata-protocol,spec): a paged search reports the counted total - #22857
Conversation
…rement first) Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
…ed under the search engine.count() takes the query's search / searchFields and expands them through the one ADR-0061 expander find runs (expandSearchOnOptions, shared with aggregate); findData passes them to the count and the countable page-local estimate arm is gone. EngineCountOptionsSchema declares the pair exactly as EngineQueryOptionsSchema does, with EngineCountOptionsParsed. Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
…n the door pin Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 141 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 af2bfae226ea4a123eb866566815c3a96eec2ac2 && git checkout af2bfae226ea4a123eb866566815c3a96eec2ac2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 12b9daf7493f30ebb5faf47e7b40eb99d2bda267 4e057d908e16aae99375c697f7420e789ffc5344 && git checkout -B drift-repro 12b9daf7493f30ebb5faf47e7b40eb99d2bda267 && git merge --no-ff 4e057d908e16aae99375c697f7420e789ffc5344
node scripts/docs-audit/affected-docs.mjs --json 12b9daf7493f30ebb5faf47e7b40eb99d2bda267
|
Contract reviewServed-tier: Read at 2026-10-11T14:33Z on card #22790 (triage 6106471041, claim 6108682129, the os-dev-report), the two #6017 declarations (6108690316, 6110016343), PR #22857's body, file list and net diff against ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #22790
Clause-②: yes (widening)
What this does
Under a search, a paged list's
totalis now the counted number of matches, andhasMoreagrees with it. This is the triage direction (6106471041), executed as ruled: one expander serves both verbs. There is no second expander, no omittedtotaland no estimate marker.@objectstack/objectql(packages/objectql/src/engine.ts):ENGINE_COUNT_OPTION_KEYSadmitssearch/searchFields.count()runs them through the existingexpandSearchOnAst, via the carrier helper the aggregate verb already used, now namedexpandSearchOnOptionsand typed for both bags. That is the expansionfindruns, with itsinternal: trueexclusion, itssearchableFields/nameFieldresolution and itssearchFieldsnarrowing. It sits at the same point in the sequence as onfindandaggregate: after thewheredoors, before the AST is built, tokens resolve and the security middlewares run.@objectstack/metadata-protocol(packages/metadata-protocol/src/protocol.ts):findData's paged arm passessearch/searchFieldstoengine.count(). Thecountableexception and its page-local estimate arm are deleted.$count=falseis unchanged. The degraded-countcatchis untouched (out of scope per triage).@objectstack/spec(packages/spec/src/data/data-engine.zod.ts):EngineCountOptionsSchemadeclaressearchandsearchFieldswith the same Zod expressions asEngineQueryOptionsSchema. The structuredsearcharm carries flag defaults, so the author and parsed states part.EngineCountOptionsParsedis therefore declared (ADR-0122,check:spec-parsed-alias), and the count schema's isomorphism pin leavestype-alias-convention.pin.test.ts(776 to 775).check:api-surface: 1 added, 0 removed or narrowed.Before / after at the public door
packages/rest/src/list-search-total-door.test.ts(new) drives theRestServerroute handlers,findData,ObjectQLand a sqliteSqlDriverover 90 rows. 75 rows matchLine: 60 intitleand 15 innotes. 15 more match only in theinternal: truecolumnhint. A middleware scopes the member to every third row, which is 25 of the 75 matches.GET ?$top=25&$search=Linetotal: 26,hasMore: truetotal: 75,hasMore: trueGET ?$top=25&$skip=50&$search=Line(last page)total: 76,hasMore: truetotal: 75,hasMore: falsePOST …/query{ search: 'Line', limit: 25 }total: 26total: 75POST …/querywithsearchFields: ['title']total: 60; the unpaged rows are 60GET ?$top=10&$search=Linetotal: 11total: 25GET ?$top, no search$count=falsewith a searchtotalomittedtotalomittedThe first-page, POST and member "before" figures were measured on base
c4e7fa5a31. The last-page figure comes from ablation A1 below, which restores the protocol's estimate arm on this branch. That is the base door's behaviour, because the engine change is inert when the count is sent no search.Mechanism hypotheses, measured
c4e7fa5a31:protocol.tshadconst countable = options.search == null;and the estimate arm (line 13321).ObjectQL.countwas gated byrejectUnknownEngineOptions(…, ENGINE_COUNT_OPTION_KEYS)over{ context, where }(line 18696).find's expansion wasexpandSearchOnAst(line 12632), withcollectInternalReadFields.yes (widening). No route exists without the spec. The drift pin inengine-unknown-option.test.tsholdsENGINE_OPTION_KEY_SETS.countequal toEngineCountOptionsSchema's shape, with no documented extras oncount. Admittingsearchengine-side without declaring it reds that pin. The remaining routes are a bypass ofrejectUnknownEngineOptionsor a private side channel, and both are ruled out.wheredeep-equal to the driver's FINDwherefor the same request. The count equals the unpaged row count on the default set (75, with the internal column excluded), on the narrowed set (60) and in the member's scope (25).check:engine-double-contractandcheck:objectql-double-limitexit 0. No double changed, because the new pin uses the real engine.searchFieldsis declared besidesearch, deliberately.findDatahandsfindboth keys, and on stored-metadata tables it narrowssearchFieldsitself (narrowStoredMetadataSearch). A count that tooksearchalone would count over the wider default set, sototalwould exceed the rows paged. On a stored-metadata table it would also count matches in the withheld body and hash columns.Landing beyond the declared file surface
The claim named three source files. The diff also touches the following files, and the widening forces each one:
packages/spec/src/data/hook-api.ts(TSDoc only) andhook-api.test.ts.HookCountQueryis anOmitofEngineCountOptions, so it gains the pair by derivation. Its TSDoc said "whereand nothing else" and quotedENGINE_COUNT_OPTION_KEYSas{ context, where }. Both became false with this change, and both are corrected. The test's drift pin ("each derived shape equals KEPT ∪ OMITTED") forces the decision: the pair is KEPT, becausectx.api.object(n).count()forwards its bag toengine.count()and now honours it. This is a spec source file beyond the [PM seat] domain:spec — ⏳ vacant #6017 declaration (6108690316), and it is flagged to the PM in the report.packages/spec/src/type-alias-convention.pin.test.ts: the isomorphism pin leaves (776 to 775).packages/spec/src/data/data-engine.test.ts: a new case checks that the count options declare the pair exactly as the find options do (JSON-schema equality, bothsearchforms).protocol.zero-limit-total.test.ts(metadata-protocol) andprotocol-data.test.ts(objectql).check:generatednamed:api-surface/data.json,authorable-surface/data.json,export-origins/data.jsonandcontent/docs/references/data/data-engine.mdx.packages/rest/src/list-search-total-door.test.ts.Tests
c4e7fa5a31.origin/mainis not merged in: neither in-flight neighbour (fix(objectql): an aborting after* hook rolls its write back on the default write door #22819, feat(metadata-core,metadata-protocol,objectql,plugin-security): thesys_metadatafamily goes tenant-less; the per-organization overlay axis retires; managed content is sealed (ADR-0131 D6/D7/D13) #15206) has landed, andorigin/maind8c7d38648touches none of these files.e85d2a4d13. The one commit after it,4e057d908e, adds the changeset and makes the door pin's page assertions soft. Everything else ran on4e057d908e: rest, spec, the typechecks, the ablations and the gates.--maxWorkers=2.@objectstack/objectqlvitest run --project local@objectstack/objectqlvitest run --project repo@objectstack/objectqltypecheck@objectstack/metadata-protocolvitest run@objectstack/metadata-protocoltypecheck@objectstack/restvitest run --project local@objectstack/restvitest run --project repo@objectstack/resttypecheck@objectstack/specvitest run --project local@objectstack/specvitest run --project repo@objectstack/spectypecheck(tsc, scripts and test layer; the three edited spec tests are in the test-layer program)The filter direction is the changed packages themselves, plus
@objectstack/rest, which hosts the door pin and compiles against the widened type.Ablations (one leg per negative pin, plus the two halves of the fix). In each leg,
scripts/ablation-replace.mjsmakes the mutation: the anchor must hit, the blob change is verified, and the restore checks that the blob equals HEAD and thatgit diff HEADis empty. The package is then rebuilt andablation-dist-preflight.mjsfinds the marker indist/. The door pin runs, the package is rebuilt again, and the preflight confirms with--absentthat the marker is gone and the tree is clean.total26 (want 75), last page 76 /hasMore: true; POST 26; one-expander (no COUNT issued); member 11 (want 25). Both controls stayed green.$count=falseignored$count=falsecontrol (totalpresent)internal: trueexclusion removed from the shared expandertotal90, member 30whereinstead of the middleware-scoped ASThasMore: true, one-expander, memberTwo first attempts did not run, and both were re-run:
ablation-replace.mjsrefused it (anchor count 1 to 1) and restored the file. It was re-run with a distinct anchor.string[]annotation thatdist/drops, so the preflight's tree reading refused (exit 91). It was re-run with--source-marker.Gates.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackre-derived 116 commands for this diff: the dispatch's 78, plus 38 from the changeset, docs, engine-double, query-options-erasure and type-check-coverage families, among others. Every one was run with its exit code recorded. The--ranreconciliation answers 116 derived, 115 run, 1 NOT MEASURED, 0 unrun.pnpm check:dual-build-cjs-loads, exit 3, PREREQUISITE NOT MET. 44 packages outside this diff's build closure have nodist/. A narrowed probe loaded every CJS entry of the three changed packages with 0 failures: spec 19, objectql 2, metadata-protocol 1.pnpm --filter @objectstack/spec run check:skill-examplesfirst exited 3, because the client SDK was not built. After building@objectstack/client-react's closure it exits 0 (262 examples).origin/main, and 7 derivation inputs changed there. The gates ran on this branch's tree.Lint is narrowed here; CI owns the repo-wide run.
eslint --no-inline-config --format jsonover the 10 touched.tsfiles reports 10 files, 0 errors and 0 warnings. Population: all 10 resolve a config (--print-config). Invariance:eslint.config.mjsenables no type-aware linting (noparserOptions.project), so this diff cannot move a verdict on an untouched file.Line budget: +301 / -62 across 15 files, 25 of the added lines generated. The four source files account for +61 / -42, mostly corrected or replaced comment text. The door pin (157 lines) exceeds the suggested 300 / 60 on its own: it carries a real-chain harness, a member scope and the driver capture.
Acceptance notes
catchinfindData, which reports a failed COUNT asoffset + page, is untouched per triage. It was not measured reachable here.DataEngineCountRequestSchema(the RPC request shape) extendsEngineCountOptionsSchema, so its generated doc rows gained the pair too.git grepfinds no runtime reader of that schema outsidepackages/spec.yes, so this PR owes one contract-review-tier review before the queue. The seat arranges it.Generated by Claude Code