fix(service-analytics): type a formula measure column from its declared returnType (#16236) - #17537
Conversation
Reproduces #16236 end to end: a `min` over a formula field declaring `returnType: 'text'` and a `max` over one declaring `'date'` are both described `type: "number"` by `AnalyticsResult.fields[]`, beside string values. Section A pins the returnType -> DimensionType translation and the anti-pass-through invariant; section B is the reproduction plus the reachability control. Committed RED on purpose so the red/green readings are taken from state. Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com>
… the wire type `measureResultType` takes the aggregated field's declared `FieldSchema.returnType` as a third input and TRANSLATES it into the `DimensionType` vocabulary — `text` -> `string`, `date` -> `time` — never relaying the literal. `number` and `boolean` answer "no correction", each for the reason its own FieldType row already carries. The absent-returnType tier is written down as a row in the module's table, not left as an implied code path: an unproven formula keeps the word its producer minted. The channel is still unwired at this commit, so the end-to-end reproduction stays RED while the rule itself is GREEN — the card's thesis isolated. Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com>
…to the measure rule `AnalyticsServiceConfig.sourceFieldMeta` gains an optional fourth member on its return, `returnType`, and the ADR-0021 result-column enrichment passes it to `measureResultType` in the same call that already reads `type`. The plugin relays the key straight off the engine's declared field, beside the three it already extracted, so a host on the plugin needs no change. Widening this hook was preferred over handing the rule the whole field definition: the extraction at the host is also a NARROWING that keeps the data engine's internal field shape out of a published contract, and it is what lets the hook document who reads each key. The enum-walk suite's `formula` bucket is renamed from `not-on-this-input` to `declared-elsewhere` — on the declared FieldType alone the verdict is still no correction, but the answer is no longer unreachable. Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 9 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 c4703f2324c4c1743591ca1907361e43eef2c2aa && git checkout c4703f2324c4c1743591ca1907361e43eef2c2aa
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin edaf3b2f7832805d4d7c544fb88d696ff8056924 91021a6c8a3ef21ab02378b0c089603e5546a6f8 && git checkout -B drift-repro edaf3b2f7832805d4d7c544fb88d696ff8056924 && git merge --no-ff 91021a6c8a3ef21ab02378b0c089603e5546a6f8
node scripts/docs-audit/affected-docs.mjs --json edaf3b2f7832805d4d7c544fb88d696ff8056924
|
docs-drift-check — the one row answered, plus the emitter-blind halfRead on the tree the bot named,
⛔ The prior finding was used as a starting point only. Both stated re-check reasons were answered on their own terms:
The delegation target was measured too, since "read the README" makes the README load-bearing: The emitter-blind halfThe bot's caveat is carried, not waved past — it says its checkout 「carried uncommitted changes, so the commit above does not fully identify what was read」 ⇒ the hand search is scoped to the WHOLE corpus, not to its one-row list. 402 doc files on that tree, 181 hand-written (excluding auto-generated
All eleven hand-written hits were read. None falsified. The closest call moves the right way: ⇒ One row owed, answered NOT falsified; emitter-blind sweep over all 181 hand-written pages finds no falsified page and four measured zeros, each with a firing control. Generated by Claude Code |
Contract review (clause ②) — PASS · head
|
|
ACCEPT — 2026-09-10T20:52Z, head All three landing pre-checks, measured:
Two things from the delivery worth keeping where the next reader will find them:
Flipped ready and armed. ⛔ Not approved and ⛔ not merged by this seat; the merge queue lands it. Generated by Claude Code |
Fixes #16236
FieldSchema.returnTypedeclares what aformulafield computes, and that key's own JSDoc names dataset measures first among its consumers. That consumer could not read it: the one channel from the host toAnalyticsServicefor a field's declared metadata —AnalyticsServiceConfig.sourceFieldMeta— returned three members and none of them wasreturnType. SomeasureResultTypereceived the bare string'formula'and every formula measure column was describednumber, including formulas declared'text'or'date'.Located by TEXT — both line numbers on the card had rotted, and so had the fix
origin/main@bea76c928returnTypedeclarationfield.zod.ts:1399packages/spec/src/data/field.zod.ts:1483analytics-service.ts:603packages/services/service-analytics/src/analytics-service.ts:694analytics-service.ts:835analytics-service.ts:1821(the ADR-0021 result-column enrichment; the only one)packages/services/service-analytics/src/plugin.ts:1051That last row is the one nobody had named, and it decided decision 1 below. The plugin already holds the whole field definition —
dataEngine().getObject(object).fields[field]— and hands three EXTRACTED keys to the service.The reproduction, driven before anything was written
packages/services/service-analytics/src/__tests__/formula-return-type-measure.test.tssection B, on unchangedorigin/main:Both values are strings; both descriptors say
number.AGGREGATE_FIELD_TYPE_COMPATIBILITYdoes not listformulaunderminormax. It is nonetheless not refused today:dataset-compiler.assertAggregateFieldTypeCompatibleexecutes only the TEMPORAL rows —if (!TEMPORAL_SOURCE_FIELD_TYPES.has(fieldType)) return;— by its own documented scope, andformulais outside that class. Section B carries a standing control for exactly this (the pair is REACHABLE), so the branch this PR adds is reached by the tree as it ships. If that full-table leg is ever executed the branch becomes unreachable and inert — never wrong. See acceptance notes.Decision 1 — the callback's shape: widen the hook. ⛔ Not the field definition
Triage left this open: 「是给
sourceFieldMeta的返回值加一个returnType,还是把字段定义整个交给measureResultType而不是三个抽出来的键。⭐ 后者一次解决"下一个需要第四个键的规则",但扩大了传给分析层的元数据面 —— 权衡后说明选了哪个。」Chosen: widen the return with one declared, optional member. The trade-off weighed, with the deciding argument the one the host wiring supplies:
plugin.ts:1051is not merely a projection — it is a NARROWING that keeps the data engine's internal field shape out of a published contract.sourceFieldMetais reached through the exportedAnalyticsServiceConfig, so handing the definition through would publish whateverdataEngine().getObject().fields[field]happens to be as the hook's declared type. That is a much larger and less deliberate surface than "one more key", and it is owned by a different layer.dist/index.d.ts: the member is optional, so a host returning today's three-member shape still satisfies the contract and gets today's behaviour on every column. Hosts onAnalyticsServicePluginneed no change — the plugin relays the key.⛔ Nothing in
packages/specmoves.returnTypealready exists there and this card only READS it, which is what keeps the change in this lane.plugin.tsis outside the declared write surface — what obliges itThe dispatch order's expected write surface named
analytics-service.ts,measure-result-type.ts,strategies/types.tsand tests.packages/services/service-analytics/src/plugin.ts:1051is not on it, and it is edited here. What obliges it:plugin.tsis the only producer of the value the widened hook declares.sourceFieldMetais not an author-facing option — measured, not assumed:AnalyticsServicePluginOptionscontains 0 occurrences ofsourceFieldMeta(firing control on the identical grep shape, same 95-line block:queryCapabilities= 1). The plugin SYNTHESISES the hook fromdataEngine().getObject(object).fields[field]and hands it to the service. So widening the declaration without editing this file ships a key that is declared, read bymeasureResultType, and never populated by the shipped host — a capability the runtime does not deliver. The alternative is not "a smaller diff"; it is a hook nobody fills.Forced, not convenient, and the boundary is visible in the diff: the edit is one destructuring member plus its note. It adds no branch, narrows nothing, and relays
returnTypeexactly astype,maxandcurrencyConfig.defaultCurrencyare already relayed on the two lines above it. Nothing else inplugin.tsmoves. ⛔ The justification is not that a sibling PR touched the file.Collision re-checked at write time rather than inherited from the claim: all 19 open PRs' file lists re-read, 0 touch anything under
packages/services/service-analytics/src/— with a firing control on that zero (the same probe correctly reports PR #17454 holdingpackages/plugins/plugin-auth/src/auth-schema-config.ts, count 1, over 242 file rows read).docs-drift-check — the one row, answered on the tree the bot named
Read on
c4703f2324c4c1743591ca1907361e43eef2c2aa(the merge of this head into base), ⛔ not on this worktree's older base.content/docs/plugins/packages.mdx— NOT falsified. Own reading, not inherited from the sibling delivery on this package; the two reasons it was re-checkable are answered directly:The page names the symbol at
:502(import { AnalyticsServicePlugin } from '@objectstack/service-analytics';) and:506(await kernel.use(new AnalyticsServicePlugin({ /* … */ }));), inside one "For Enterprise Applications" composition example with the options elided. It also carries:17(a table row listingservice-analyticsamong 16 platform services) and:213–:219(the package's own entry: "Multi-driver analytics with built-in NativeSQL and ObjectQL strategies (the lowest-priorityInMemoryStrategyis not built in …)", plus a README link) — strategy composition, not a config surface.The sentence that decides it is the blockquote at
:508: "The official plugin and service packages above export plugin classes you instantiate (new XPlugin(options)) — check each package'sREADME.mdfor its option shape." The page DELEGATES the option shape rather than stating it, so there is no sentence about this plugin's config surface for a new member to falsify.Both re-check reasons answered on their own terms rather than waved past:
plugin.ts, so the construction path is in scope. It is — and the page's only construction site elides the options entirely ({ /* … */ }), so it states nothing about that path beyond "you instantiate it with options".sourceFieldMetais not on the type this page's delegation points at:AnalyticsServicePluginOptionscarries 0 occurrences of it (control:queryCapabilities= 1). The member added is onAnalyticsServiceConfig, which the plugin builds internally.The delegation target was measured too, since a page that says "read the README" makes the README load-bearing:
packages/services/service-analytics/README.mdcontains 0 occurrences ofsourceFieldMetaand 0 ofreturnType(firing control, identical shape, same file:AnalyticsServiceConfig= 4). Its:174sentence — "AnalyticsServiceConfigis the wider surface the plugin builds — it addslogger,strategies,fallbackService, … and the dataset resolvers on top of the plugin options above" — already omittedsourceFieldMetabefore this diff and is an open-ended list, and this diff adds a member to that hook's RETURN rather than a key toAnalyticsServiceConfig, so its accuracy is unmoved in both directions. See acceptance notes.The emitter-blind half — a measured zero where there is one, and read hits where there are not
The bot's own caveat is carried rather than ignored: it says its checkout 「carried uncommitted changes, so the commit above does not fully identify what was read」, so the hand search is scoped to the WHOLE corpus, not to its one-row list. 402 doc files on the bot's tree, 181 of them hand-written (excluding the auto-generated
references/and release-ownedreleases/). Probes are by INPUTS and concepts, not by emitter identifiers, and every count carries a firing control of the identical grep shape on the same corpus.sourceFieldMetameasureResultTypeDimensionTypereturnTypefields[].type/ "measure column"AnalyticsResultformuladatasetanalyticsAnalyticsServicePluginThe eleven hand-written hits were READ, not counted. None is falsified:
data-modeling/formulas.mdx— the closest call, and the one that moves in the right direction. At:128it says "Consumers read the declaredreturnTypeinstead of re-parsing the expression (record-title eligibility, for one: a formula is title-eligible only when itsreturnTypeis'text')". It names one consumer, explicitly as one of several, and states nothing about a measure column'stype. This PR adds a consumer, so the sentence becomes more true rather than less. Its authoring examples at:99–:107and:415–:421are unaffected — no key is added and no accepted value moves.data-modeling/field-types.mdx:417anddata-modeling/validation-rules.mdx:352— key tables givingreturnTypeas'number' | 'text' | 'boolean' | 'date', the second adding "Optional inferred result type". Both still exact: this PR reads those four members and adds none, and the optionality they state is precisely the absent tier written down above.getting-started/common-patterns.mdx:143— an authoring example,returnType: 'number'. Unaffected.data-modeling/analytics.mdx:216— "each measure column carries itslabelandformaton the resultfields". Nameslabelandformat; says nothing abouttype, which is the key this PR moves.api/data-api.mdx:434— "Each entry carriesnameandtypeand, when the producer declares them,label,format,currency,percentScaleandbuiltinAggregate". A statement about which MEMBERS exist and which are optional; this PR changes neither, only the VALUE oftypefor one population. Its response example at:414–:429showsrevenue_sumandcountas"number"— asumand acountover non-formula fields, both outside this PR'smin/max-over-formulapopulation, so the example stays exactly correct.api/client-sdk.mdx:319— readsresult.fields[0].name. Nottype.data-modeling/queries.mdx:387,deployment/validating-metadata.mdx:169,protocol/objectql/query-syntax.mdx:940,ui/reports.mdx:195— all about measure NAMING and chart axis keying, none about column typing.⇒ One row owed, answered NOT falsified on the bot's tree with its reasoning re-derived; emitter-blind sweep over all 181 hand-written pages: no falsified page, four measured zeros each with a firing control.
Decision 2 —⚠️ the mapping is a TRANSLATION, and one invariant keeps it one
Triage raised this to a hard condition: 「
returnType的四个成员 →DimensionType的 wire 词汇(string/number/boolean/time/geo)。text→string,date→time。 ⛔ 不要直接把returnType的字面量当 wire 词用 —— 两套词汇不是一回事。」FORMULA_RETURN_TYPE_RESULTinmeasure-result-type.ts:returnTypetext'string'textis not a wire worddate'time'dateis not a wire wordnumberNUMERIC_VALUE_TYPESrow: the producer'snumberis already rightbooleanBOOLEAN_VALUE_TYPESrow: three readings disagree on whatmin/maxover a boolean returnsThe pin that stops this degrading into a pass-through is not four expectations — it is one invariant: ⛔ NO member of
returnTypeis answered by its own spelling. Four hand-written expectations would let a relay survive on the two rows where the two vocabularies happen to share a spelling. All four members satisfy the invariant here, so a relay reintroduced anywhere reds on every row it touches — including a fifth member added to the spec enum later. The enum walked is read offFieldSchemaitself, so a member added upstream lands as an exhaustiveness failure rather than falling silently into the "cannot answer" tier.returnTypetier is WRITTEN DOWN, not impliedTriage: 「在
measureResultType的表里明确记一行「formula且returnType缺席 ⇒ 不作答,保留生产者给的词」,⛔ 不要让它成为一条只存在于代码路径里的隐含行为。」Three places, none of them a code path:
| `formula`, `returnType` ABSENT | formula | no correction — do not answer, keep the word the producer minted |.###formulawith NOreturnType— a ROW, not an implied code path, which also states the same verdict for a word outside the declared four.an UNPROVEN formula keeps the producer's word). The enum-walk suite'sformulafixture wires{ type: 'formula' }with noreturnType, so it drives the absent tier on the live path as a side effect of existing coverage.The bucket in
measure-result-type.test.tsis renamednot-on-this-input→declared-elsewhere: on the declaredFieldTypealone the verdict is still no correction, but the answer is no longer unreachable.RED before GREEN, proven by state
Staged deliberately so the card's thesis is isolated rather than asserted, each reading taken from a COMMITTED tree:
372f3b12aAssertionError: expected 'number' to be 'string'·expected 'number' to be 'time'563dbe756expected 'number' to be 'string'/'time'⇒ the rule can answer and the CHANNEL is what was missing. That is the card's thesis, measured.91021a6c8measure-result-type.test.tsandpreview-column-enrichment.test.tsAblation — the observed direction, not the predicted one. The translation table was mutated into a relay (
text: 'text', date: 'date', number: 'number', boolean: 'boolean') from committed state:Observed: RED, 8 failed / 9 passed, and the anti-pass-through invariant is among them, failing on all four members. Restored by STATE, never by exit code:
git hash-objectback to9af67774a,git diff HEADempty,git status --porcelainempty, occurrence counts back to 1 / 0. The restore isgit checkout HEAD -- $ABSOLUTE_PATHin anEXIT INT TERMtrap, never a baregit checkout --.The instrument reads
src, notdist: the test imports../measure-result-type.jsrelative, inside its own package, and the staged RED at563dbe756changed its result with no build in between — which is the proof rather than the claim.Verification
node scripts/pm/dispatch-gates.mjs --ran RANFILE --repo objectstack-ai/objectstack, every command recorded asCOMMAND :: exit CODE. All 61 exit 0.check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt. The prerequisite was satisfied with lint.yml's ownturbo run build --filter='./packages/*' --filter='./packages/*/*'(VERDICT command-exit 0, 72/72 tasks) and all three re-run to exit 0. ⛔ None was rounded up.pnpm --filter @objectstack/service-analytics typecheck— exit 0. It reaches*.test.ts: an earlier run failed FROM inside the new test file (error TS2305on anExecutionContextimport path), which is direct evidence of coverage rather than a claim about it.pnpm --filter @objectstack/service-analytics test— 108 files, 2313 tests, all passing.pnpm --filter '@objectstack/service-analytics^...' build— VERDICT command-exit 0.pnpm lintwas run WHOLE, not narrowed —eslint . --no-inline-config, 6584 files, 0 errors, 0 warnings, exit 0, at91021a6c8. File count read from--format json; the population is eslint's own resolution of., not an estimate. (For the record the config enables no type-aware linting at all —eslint.config.mjssays so at line 328 with its own positive control — so this diff could not have moved an untouched file's verdict either way.)scripts/pm/os-verify-lock.sh; itsVERDICT command-exitline is the verdict quoted above, never a bare$?.Clause-② — both limbs measured
Clause-②: yes
AnalyticsServiceConfig.sourceFieldMeta's declared return goes from a three-member object to a four-member one, so a host MAY now return a key the hook did not previously accept.dist/index.d.ts, not assumed:sourceFieldMetathere now reads{ type?: string; defaultCurrency?: string; max?: number; returnType?: string }.AnalyticsResult.fields[].typealso changes its observable value for formula measure columns.FORMULA_RETURN_TYPE_RESULTandFormulaReturnTypeare not package-public —measure-result-type.tsis not re-exported fromsrc/index.ts, anddist/index.d.tscarries 0 declarations of either name (its one textual mention, line 957, is a JSDoc{@link}in prose). So Limb B stands on the hook and the payload, not on those two.This matches the seat's claim-time declaration, taken on the mechanical floor; measuring it did not move it.
Acceptance notes — noted, not filed
formularefusal is not executed.AGGREGATE_FIELD_TYPE_COMPATIBILITYrefusesmin/maxoverformula(「formulacarries a declaredreturnType, but it is VIRTUAL in SQL storage (no column is emitted)」), whileassertAggregateFieldTypeCompatiblereturns early for anything outsideTEMPORAL_SOURCE_FIELD_TYPES. ⛔ Not filed, and not a contract violation: both the spec module (「It refuses nothing itself. The refusals are the two consumer legs」) and the compiler's own scope note declare the deferral, and name No layer refuses an incoherent aggregate / field-type pair — a dataset measureavgover a datetime works on SQLite and errors on Postgres #16099's full-table leg — waiting on [Decision] The ruled aggregate × field-type table cannot be executed in full: its string rows contradict #15768's typing and its boolean rows contradict ruling #11152 — enforce, amend, or leave partly decorative? #16785's amendment — as its owner. The successor that will touch this file is that leg. Recorded here because it decides this branch's future: if the full-table leg lands, no formula measure reaches a column descriptor and the translation added here becomes inert rather than incorrect. ⛔ Nothing in this PR accepts a pair that was previously refused; the accept set is untouched in both directions.returnTypeis relayed from the engine's declared field unvalidated, exactly astype,maxandcurrencyConfig.defaultCurrencybeside it already are — same provenance, same confidence. Deliberately not narrowed at the plugin seam: a second copy of the accepted vocabulary there is one that can drift fromFieldSchema.returnType, and the single copy lives inFORMULA_RETURN_TYPE_RESULT, which tiers anything else as "cannot answer".Changeset
.changeset/16236-formula-return-type-measure-column.md,minoron@objectstack/service-analytics— a published behaviour change (fields[].typemoves for formula measure columns) plus an additive member on a published hook. Notskip-changeset: both halves ship inside the package'sfiles[].Authored by Claude Code in session
session_01ToDPcx9AESFubJkDiFMtKW. (This line is prose, not the footer block: measured on this PR, a REST body EDIT appends its own bare footer, so a body that ships one of its own is stored with two.)Generated by Claude Code