Repository navigation
fix(service-analytics): a measure column takes the source field's currency only under currencyMode 'fixed' (#20091) - #20119
Conversation
…ncyMode, end to end (#20091) Measurement, recorded BEFORE the fix. Tree: base fa00ebf plus this test file only; AnalyticsServicePlugin booted over a real ObjectQL engine on SqliteWasmDriver; ExecutionContext.currency = USD; every field's authored defaultCurrency = EUR. Currency on the queryDataset result column, live path / draft-preview path: case raw reg. parsed reg. fixed, EUR EUR / EUR EUR / EUR dynamic, EUR EUR / EUR EUR / EUR <- defect no mode, EUR EUR / EUR EUR / EUR <- defect currencyConfig {} (parsed => dynamic CNY) USD / USD CNY / CNY <- defect (parsed) no currencyConfig USD / USD USD / USD explicit measure currency JPY (dynamic fld) JPY / JPY JPY / JPY explicit measure currency JPY (fixed fld) JPY / JPY JPY / JPY non-monetary control (number field) none / none none / none Five of the committed cases are red on this tree; the fix follows. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…rrencyMode 'fixed' (#20091) The plugin's sourceFieldMeta relay passed currencyConfig.defaultCurrency through regardless of currencyMode, so queryDataset stamped a dynamic field's defaultCurrency (or the schema's placeholder CNY on a parsed empty config) onto the result column where the tenant default belongs. Only currencyMode 'fixed' pins a field's currency (CurrencyConfigSchema); the relay now answers undefined otherwise, and the chain falls through to ExecutionContext.currency. An explicit measure currency still wins. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF 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 0715c144702dcd3f30fb491cf964cb34b1ba9f94 && git checkout 0715c144702dcd3f30fb491cf964cb34b1ba9f94
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7c1039b388f46222b0029d93a03cefa9fbf5f67f 131de3a37a314a4ad55ebd2fe081247e2904d613 && git checkout -B drift-repro 7c1039b388f46222b0029d93a03cefa9fbf5f67f && git merge --no-ff 131de3a37a314a4ad55ebd2fe081247e2904d613
node scripts/docs-audit/affected-docs.mjs --json 7c1039b388f46222b0029d93a03cefa9fbf5f67f
|
… text states the fixed condition (objectstack-ai#20179) Part of objectstack-ai#20126 Clause-②: no This PR covers the non-governed sites of the currency-mode class-closure card. It does not close the card. The remainder is listed at the end: the Tier H skills site and the enumerating pin. ## What was wrong `CurrencyConfigSchema` declares two modes. With `currencyMode: 'fixed'`, the field has one currency, `defaultCurrency`. With `dynamic`, the default, the field has no currency of its own and shows the tenant default currency. Every runtime reader follows that rule: - `packages/spec/src/data/field.zod.ts`: the precision refinement judges `defaultCurrency` only under `'fixed'`. The `FieldSchema` guidance says "A field without one uses the tenant default at runtime". - `packages/services/service-analytics/src/plugin.ts` (`sourceFieldMeta`): relays `defaultCurrency` only under `'fixed'`, as landed by the runtime member of this family (objectstack-ai#20119). - objectui's `resolveFieldCurrency`, which the field faces call: reads `defaultCurrency` only under `'fixed'`. Read from objectui's changeset `10422-currency-mode-dynamic-tenant-default.md` at its `main` (`a2f361b`). With no tenant currency set, the amount shows as a plain number. Four kinds of text still said otherwise: 1. **The spec describe** (card site 1). It called `dynamic` "user selectable". No key, stored value or runtime path offers a per-record currency choice. The value is a bare number (ADR-0104 D1), and the code lives in field config. The describe ships in the published `@objectstack/spec` and in the generated `content/docs/references/data/field.mdx` (2 rows). 2. **`content/docs/api/data-api.mdx`** (card site 3). It named "the ADR-0053 currency chain" with no mode condition. ADR-0053 is the date/datetime record and never mentions currency. 3. **service-analytics test text.** Two test files still called the chain "ADR-0053": the `preview-column-enrichment.test.ts` header table and two `it` titles, and a `query-dataset.test.ts` section comment. The objectstack-ai#20119 PR noted the test-file labels and left them as they were. 4. **Hand-written `content/docs/**` lines** that taught `defaultCurrency` without the `fixed` condition. The census is below. ## What changes - `field.zod.ts`, **`currencyMode` describe only.** It now says what each mode does. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): no field currency, amounts show in the tenant default (`localization.currency`, or a plain number when that is unset), and `defaultCurrency` is not read. Neither mode is a per-record choice. `gen:docs` regenerated the two `field.mdx` rows. No key, value, default or parse output changes. `.changeset/20126-currency-mode-describe.md` is an `@objectstack/spec` patch. - **`data-api.mdx`.** The ADR-0053 citation is gone. The callout now states the chain as it runs: measure `currency`, then the source field's `currencyConfig.defaultCurrency` only when that field is `currencyMode: 'fixed'`, then the tenant default. It also says where the chain runs. It runs on the dataset query (`POST /analytics/dataset/query`). The cube query this page documents (`POST /analytics/query`) mints no column `currency`, and `currency-mode-relay.test.ts` pins that cube-face fact. - **service-analytics, comments and test titles only.** The header table row, the two `it` titles, one in-test comment ("no field default" now reads "no fixed field currency") and the `query-dataset.test.ts` section comment now describe the chain with the fixed condition. The old ADR-0053 label is gone. No assertion or runtime line changes. - **Census fixes** (table below): `validation-rules.mdx`, `common-patterns.mdx` and `protocol/objectql/types.mdx`. ## Census: every hand-written `content/docs` line naming `defaultCurrency` (at base `84880f9`) Rule, from the card: the line must also carry `fixed`, or say it is not the displayed currency under `dynamic`. The census covers 13 lines, the same per-file counts the dispatch named (3 / 4 / 1 / 2 / 3). `releases/` is never edited. | file:line | verdict | why | |:---|:---|:---| | `data-modeling/field-types.mdx:156` | pass | the mode row states `fixed` and `dynamic` | | `data-modeling/field-types.mdx:157` | pass | "under `fixed`; not the displayed currency under `dynamic`" | | `data-modeling/field-types.mdx:160` | pass | the example carries `currencyMode: 'fixed'` | | `data-modeling/fields.mdx:77` | pass | `currencyMode: 'fixed'` line | | `data-modeling/fields.mdx:79` | pass | "the column's currency under 'fixed'" | | `data-modeling/fields.mdx:86` | pass | "not the displayed currency" under `dynamic` | | `data-modeling/fields.mdx:87` | pass | "`fixed` pins the column to `defaultCurrency`" | | `data-modeling/validation-rules.mdx:156` | changed | "3-character currency code" with no mode condition. It now adds "the field's currency under `fixed`; not the displayed currency under `dynamic`" | | `getting-started/common-patterns.mdx:53` | changed | `{ precision: 2, defaultCurrency: 'USD' }` has no mode, so it is `dynamic`, and the budget shows the tenant currency, never USD. Added `currencyMode: 'fixed'` | | `getting-started/common-patterns.mdx:82` | changed | same, in the map-format tab | | `protocol/objectql/types.mdx:297` | changed (line shape) | The YAML block already carried `currencyMode: fixed` on :296, but this line did not carry it. Added the comment "the field's currency under fixed" so the line meets the pin | | `protocol/objectql/types.mdx:308` | changed | "The currency code lives once on the field definition (`currencyConfig.defaultCurrency`)" holds only under `fixed`. It now states both modes | | `protocol/objectql/types.mdx:315` | pass | carries `fixed` (`currencyMode: fixed \| dynamic`). Left as it was, because the callout above now states the condition | Two more lines in `validation-rules.mdx` sit in the same `currency` table and belong to the same class, although neither names `defaultCurrency`: - `:155`, the `currencyMode` row, repeated card site 1's "`dynamic` (user-selectable)" wording. It now matches the describe and adds this page's validation fact: under `fixed`, an authored `precision` must agree with the currency's ISO 4217 fraction digits. - `:158` said "The per-record currency **code**", which contradicts ADR-0104 D1. It now says the code is never stored per record. Both sit inside a census-hit file and table, and both are mechanical rewrites in the wording of the accepted objectstack-ai#20118 / objectstack-ai#20120 pages. Pin readings after this PR, over the card's own scopes. The census script ran from the scratchpad and was not committed. - The `defaultCurrency` line rule over `content/docs/**/*.mdx` (without `releases/`): 5 failing at base, **0** at head. - `ADR-0053` within five lines of `currenc`, over `content/docs/**`, `packages/services/service-analytics/src/**` and `skills/**`: 5 hits at base (`data-api.mdx:467`, `preview-column-enrichment.test.ts:51/:282/:288`, `query-dataset.test.ts:125`) plus the skills site, and **1** at head. The one left is `skills/objectstack-ui/rules/dashboards.md:308`, which is the Tier H remainder. ## Tests and gates All readings below were taken on head `25b085386`. That head is this branch plus a merge of `main` at `e2c4e125f`, and the merge brought nothing into this PR's files. Before the gates ran, all 71 package build tasks passed (`turbo run build --filter='./packages/*' --filter='./packages/*/*'`, 71/71). - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 109 commands. Each was run and its exit code recorded, and all 109 exit 0. The `--ran` reconciliation answers: "109 derived, 109 run, 0 NOT-MEASURED, 0 UNRUN". The derived set includes `check:docs`, `check:generated` (15 of 15 current), `check:api-surface`, `check:authorable-surface`, `check:doc-authoring`, `check:nul-bytes`, `check:skill-examples`, `check:dual-build-cjs-loads` and `check:type-check-debt`. - **Roster gates.** Six roster families keep their roster in a directory this PR touches, and all six exit 0: `check-changeset-fixed`, `check:meta-url-spelling`, `check:spec-changes`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`. - **This body.** Run against this text, `check-partof-closing-keyword` exits 0. - **`@objectstack/spec`.** - `pnpm --filter @objectstack/spec test`: 540 files, 15820 passed, 2 todo. - `pnpm --filter @objectstack/spec run typecheck`: exit 0. - No spec test pins the old describe text. The phrase "user selectable" appears only in the describe and its generated page. - **`@objectstack/service-analytics`.** - `pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2` on the two edited files: 2 files, 44 tests passed. - `pnpm --filter @objectstack/service-analytics run typecheck`: exit 0. - `tsc --listFiles` includes both edited test files (2 hits; the control `src/plugin.ts` has 1). - **Lint, narrowed to the 3 touched TypeScript files** (`pnpm lint` over the whole repo is CI's run): - `eslint --no-inline-config --format json` exits 0, and the JSON reports 3 files, 0 errors and 0 warnings. - `eslint --print-config` puts the files in the linted population, with 5 rules on. - `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so a change to these files cannot move a verdict on any untouched file. - **No ablation.** The diff changes text only: a describe, docs, comments and test titles. No assertion or runtime line changes, so no negative pin exists to ablate. ## Acceptance notes - **The `defaultCurrency` describe is left as it was.** It reads "Default or fixed currency code". The claimed surface is the `currencyMode` describe only. The generated row meets the card's pin, because it carries `fixed`, and the rewritten `currencyMode` row beside it now states the condition. "Default" can still read as a dynamic-mode default. That belongs to this family and is noted for the closure card, not changed here. - **More ADR-0053-for-currency sites exist outside the card's pin scopes.** They are the same family and are not in this PR's claimed surface: - `packages/spec/src/contracts/analytics-service.ts:80`: the published TSDoc on `AnalyticsResult.fields[].currency` reads "ADR-0053 currency chain … source-field default → tenant default", with no mode condition. It ships in the `@objectstack/spec` typings. - `packages/spec/src/data/percent-scale.ts:14`: a comment saying "mirrors the ADR-0053 currency chain". - `packages/spec/liveness/dataset.json` (the `currency` row's evidence text): "the ADR-0053 chain … the source field's `defaultCurrency`". - The card's pin scopes (`skills/**`, `content/docs/**`, `service-analytics/src/**`) do not reach `packages/spec/src/**`. These are noted for the closure card. - **A strictly line-based pin would go red on semantically correct skill lines.** `skills/objectstack-data/rules/field-types.md` lines :55, :183, :184, :191 and :192 were corrected and accepted with objectstack-ai#20120. Each states the condition across a wrapped sentence or a two-line comment, but none carries `fixed` on its own line. Whoever writes the enumerating pin must judge a sentence or block, not a single line, or reshape those lines in the same Tier H PR. - **`data-api.mdx` makes a wider claim than currency.** The callout says `label`, `format` and `percentScale` are resolved server-side on this page's `fields[]`. That is a source read and was not measured. Both cube strategies push only `name` and `type` (`native-sql-strategy.ts:1247/:1252`, `objectql-strategy.ts:1960/:1964`), and the ADR-0021 column enrichment runs only on `queryDataset`. Only the currency sentence was in scope and was corrected. Carrier: none, noted and not filed. - **Three `query-dataset.test.ts` titles are left as they were.** They say "inherits the field defaultCurrency", "a currency field with no defaultCurrency" and "wins over the field default". They cite no ADR, so they are outside the claimed surface. The section comment above them now says that `sourceFieldMeta().defaultCurrency` is the field's fixed currency. ## Remainder of objectstack-ai#20126 (not in this PR) - `skills/objectstack-ui/rules/dashboards.md:304`–`:308`: the unconditioned chain and the ADR-0053 citation. This is Tier H and gets its own PR, per triage execution note 1. - The enumerating pin: a test in the owning package, added by whichever PR closes the last site (no new `check-*` gate). See the line-based caveat above. --- _Generated by [Claude Code](https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…tstack-ai#20126) (objectstack-ai#20415) Fixes objectstack-ai#20126 Clause-②: no Remainder (c) of the currency-mode class-closure card: the family's one enumerating pin. Remainder (b) landed in PR objectstack-ai#20217, the skills half (objectstack-ai#20196) in PR objectstack-ai#20256, and the runtime member (objectstack-ai#20091) in PR objectstack-ai#20119, so this test-only PR closes the card. ## What the pin is One new test in spec's `repo` vitest project: `packages/spec/src/data/currency-mode-family-closure.pin.test.ts`. The maintainer's ruling on objectstack-ai#20088 asked for one enumerating pin in the owning package and ⛔ no new `check-*` gate. `packages/spec` owns `CurrencyConfigSchema`, and its repo project already walks `skills/**` and `content/**`. The pin enumerates the corpus, not the sites. A new site anywhere in the radius reddens it, as well as a regression of an old one. - **Rule A.** Every `defaultCurrency` in `skills/**/*.md` and `content/docs/**/*.mdx` sits in a unit that carries `fixed`, or that says it is not the displayed currency (or not read) under `dynamic`. - **Rule B.** The date record's number within five physical lines of `currenc` (any case) counts 0. The matcher reads `ADR-0053`, `ADR 0053` and the `adr/0053-` file link. The roots are `skills/**`, `content/docs/**` and `packages/services/service-analytics/src/**`. Per the family's appended sites 5-10 (ACCEPT `5853407641`, triage `5853734589`), they also include `packages/spec/src/**`, `packages/spec/liveness/**` and `examples/**`: non-code files anywhere, plus `.ts` under each app's `src/`. - **Runtime member.** It is objectstack-ai#20091's own pin, `packages/services/service-analytics/src/__tests__/currency-mode-relay.test.ts`, on `main` (`git merge-base --is-ancestor 6780e34 HEAD` exit 0). The closure pin only checks that this file still stands, and ⛔ does not re-assert the resolver. - **Site table.** Each family site (1-10, the three pages objectstack-ai#20090 landed, and 11-14) is mapped to the leg that guards it. The test asserts each file's membership in each corpus, so if a site moves out of the radius, the pin goes red. ## Outside the corpus, and why - **Released entries.** This covers every `CHANGELOG.md` and `content/docs/releases/**`. The seat ruled sites 11-14 (the released 17.0.0 entries) accepted history, since a released entry records what shipped (claim `5866047669`). The two package CHANGELOGs sit outside every root by construction. `examples/*/CHANGELOG.md` is inside a root and is skipped by name. The test asserts both exclusions, with a live control proving the by-name skip has something to skip. - **The pin's own file.** It quotes both rules and their fabricated controls. ## The three design facts (`5855122068`), each answered in the pin's header 1. **Test titles.** Rule A reads published prose only (`.md` / `.mdx`), so no test title is judged by it. The `field.test.ts` title stays outside by corpus, not by exemption. Rule B does read test files inside its roots. 2. **Negation versus citation.** Rule B does not try to tell them apart: a negation within the window counts too. Currency text has no reason to name the date record; the pin's message says to cite ADR-0104. 3. **Wrapped keys.** Both rules read logical text. A key hyphen-wrapped at a line end (`default-` / `Currency`, `ADR-` / `0053`) rejoins after the next line's comment prefix is stripped. **Unit for rule A: not the line.** A strict line reading reds on 5 correct, accepted lines in `skills/objectstack-data/rules/field-types.md` (ACCEPT `5853407641`'s pin note). Instead, the unit is: - in prose, the sentence across wrapped lines; - in a table, the row; - in a fenced comment, the sentence of its comment run; - in fenced code, the innermost object literal holding the key, or the sibling mapping in YAML. So one `fixed` field in a fence never vouches for a neighbour's bare `defaultCurrency`. There is one shape exemption: a parenthesised key list that also names `currencyMode`, `(currencyMode, defaultCurrency)`. It names keys and teaches no reading; this is `field-types.md:55`. ## Measured on this branch's base `dcd3bceaa` (assumption 1 holds) - **Rule A:** 445 files in the corpus. 34 `defaultCurrency` occurrences in 11 files; 0 failing, 1 of them the key-list exemption. - **Rule B:** 2550 files in the corpus, 0 hits. 65 date-record lines remain, all outside the window (date uses). - **No live text site:** no text site was edited in this PR. ## Declared radius (AGENTS.md: a test that reads outside its package is spelled so the gate can see it) Rule B's example-code leg is new radius for `@objectstack/spec#test:repo`. It is declared in two places: - `scripts/cross-package-test-inputs.mjs` gets `examples/*/src/**/*.ts`; - `turbo.json` gets the matching `$TURBO_ROOT$/...` input. The glob is deliberately ⛔ not `examples/**/*.ts`. That glob covers the CRM example's smoke test, which is the dispatch-gates self-test's outside-packages specimen. The declaration comment states that the glob reaches the two translation tests inside the todo app's `src/`. The pin's site table quotes the showcase dataset's path, which holds the glob on the roster. `check:cross-package-test-inputs` and `check:ci-filter-parity` are both green. ## Verification All readings are at head `7fc9f86b`, the branch's only commit; every build and test ran through `scripts/pm/os-verify-lock.sh` (`VERDICT command-exit 0` each). - **The pin alone:** `pnpm --filter @objectstack/spec exec vitest run --project repo --maxWorkers=2 src/data/currency-mode-family-closure.pin.test.ts` → `Test Files 1 passed (1)` / `Tests 6 passed (6)`. - **Ablation, one leg per rule** (from the committed state, `scripts/ablation-replace.mjs` in wrap mode, under the lock). The expected direction was red, and both legs went red: - Leg A drops ` (only under currencyMode: 'fixed')` from `skills/objectstack-ui/rules/dashboards.md`, site 2's regression. Anchor 1 → 0, blob `570341f3` → `8e0c44f3`. Rule A goes red with exactly `skills/objectstack-ui/rules/dashboards.md:307`: `Tests 1 failed / 5 passed`. Restored: blob equals HEAD `570341f3`, and `git diff HEAD` is empty. - Leg B inserts ` (ADR-0053)` after the key in `examples/app-showcase/src/ui/datasets/chart-gallery.dataset.ts`, site 10's regression and the newly declared example-code radius. Anchor 1 → 0, blob `31348e15` → `1ca43894`. Rule B goes red with exactly `examples/app-showcase/src/ui/datasets/chart-gallery.dataset.ts:47`: `Tests 1 failed / 5 passed`. Restored: blob equals HEAD `31348e15`. - The first leg-B attempt was a no-op and is not a reading: its replacement embedded its own anchor, and the tool refused (anchor 1 → 1) and restored before any test ran. It was re-run with an anchor the replacement does not contain. - Restored leg: `Tests 6 passed (6)`. `git diff HEAD` is 0 bytes and `git status --porcelain` is empty. - **spec, both projects:** - `vitest run --project repo`: `Test Files 36 passed (36)` / `Tests 640 passed (640)`. - `vitest run --project local`: `Test Files 561 passed (561)` / `Tests 16517 passed | 1 todo (16518)`. - `pnpm --filter @objectstack/spec run typecheck` exits 0, and `check:test-typecheck` is OK. `tsc -p tsconfig.test.json --listFilesOnly` lists the new file, so the typecheck really reads it. - **Builds:** `pnpm --filter @objectstack/spec build` exits 0. `turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2` gives `Tasks: 71 successful, 71 total`. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 96 commands over the 4 changed paths, and every one was run. `--ran` gives `96 derived, 96 run, 0 NOT-MEASURED, 0 UNRUN`. On the first pass, four gates exited 3 because their prerequisite packages were not built. They exited 0 after the full build: `check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt`. - **Readers of the edited declaration module:** each exits 0. - `node scripts/pm/dispatch-gates.mjs --self-test`: 1976 cases, including "no hint of this gate reaches a test file outside packages/**". - `node scripts/check-ci-filter-parity.mjs --self-test`. - `node scripts/partition-test-shards.mjs --self-test`. - `node scripts/check-published-list-mirrors.mjs`. - `node scripts/check-examples-live-imports.mjs`. - **Lint, narrowed** (CI owns `pnpm lint`): - `eslint --no-inline-config --format json` on the two lintable changed files gives 2 files / 0 errors / 0 warnings; `--print-config` gives them 4 and 2 active rules. - `turbo.json` and `vitest.repo-tests.json` answer "File ignored because no matching configuration", so they are outside eslint's population. - `eslint.config.mjs` sets no `parserOptions.project`, so there is no type-aware linting, and no untouched file's verdict can move. - **Ships nothing:** spec's `files[]` is `dist`, `json-schema`, `liveness`, `src/**/*.zod.ts` and so on. A grep of the published paths for the pin finds 0 files; the control grep for `CurrencyConfigSchema` in `dist` finds 30. So the diff takes `skip-changeset`. - **Base:** the branch is one commit behind `origin/main` (`2c310705`). That commit adds two `packages/spec/src/data` files with no date-record token, so rule B cannot move on the merge. Not merged here. ## Acceptance notes (not filed) - `.changeset/*.md` is outside the corpus. It is where released CHANGELOG text originates, so a pending changeset citing the date record for currency would not redden this pin before release. Carrier: none; noted only. - The `user selectable` wording that site 1 corrected is not a rule of the card's pin definition, so it is not guarded here. - Example tests (`examples/*/test/**`, `examples/*/e2e/**`) are outside rule B's example-code leg, which follows the declared radius above. - `node scripts/pm/measurement-claim-triage.mjs --self-test` (run as a reader of the edited module) is red on the base, naming only `scripts/check-dts-references.mjs:74` (UNTRIAGED), a file this PR does not touch. No workflow or package script invokes it. Carrier: none. - The objectui pointer in `5855122068` (objectui `packages/i18n/src/currency.ts`, `CurrencyField.tsx`) is another repository and is not read here. --- _Generated by [Claude Code](https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20091
Clause-②: no
What was wrong
AnalyticsServicePluginpasses each source field's metadata to the service throughsourceFieldMeta. That hook passed oncurrencyConfig.defaultCurrencywithout readingcurrencyConfig.currencyMode.queryDatasetresolves a monetary measure column'scurrencyin this order: the measure's owncurrency, then that relayed value, thenExecutionContext.currency. So adynamicfield'sdefaultCurrencyshowed on the dataset result where the tenant currency belonged. The spec says otherwise:CurrencyConfigSchemadefaultscurrencyModeto'dynamic'. Its own precision refinement treatsdefaultCurrencyas the field's one currency only under'fixed'.FieldSchemaguidance says a fixed currency iscurrencyConfig: { currencyMode: 'fixed', defaultCurrency: … }, and "A field without one uses the tenant default at runtime".objectstack-ai/objectui#10461 changed the renderer's
resolveFieldCurrencyto readdefaultCurrencyonly under'fixed'. The renderer reads an explicit columncurrencyfirst, though, so the analytics faces kept overriding that rule.Measured before the fix (commit
9a98c59b44: basefa00ebf447plus the new test file only), and afterAnalyticsServicePluginbooted over a realObjectQLengine onSqliteWasmDriver, with the object registered on the engine.ExecutionContext.currency = 'USD', and every authoreddefaultCurrencyisEUR. Each cell is the result column'scurrencyon the live path / the draft-preview path ofqueryDataset:ObjectSchema.parse), before{ currencyMode: 'fixed', defaultCurrency: 'EUR' }{ currencyMode: 'dynamic', defaultCurrency: 'EUR' }{ defaultCurrency: 'EUR' }(no mode){}(parses todynamic+ the schema'sCNY)currencyConfigcurrency: 'JPY'over the dynamic fieldcurrency: 'JPY'over the fixed fieldnumberfield)Bold cells break the spec rule. The "before" columns are also in the message of commit
9a98c59b44. The "after" column was measured on head131de3a37awith the same probe.What "no mode" looks like at the relay.
SchemaRegistry.registerObjectstores what it is handed and does not parse. A rawregisterObjectcall therefore arrives with nocurrencyModekey at all. The declared-stack path parses first, which fills incurrencyMode: 'dynamic', and fills indefaultCurrency: 'CNY'when the author wrote none. Measured withObjectSchema.parse:{ defaultCurrency: 'EUR' }parses to{"precision":2,"currencyMode":"dynamic","defaultCurrency":"EUR"}, and{}parses to{"precision":2,"currencyMode":"dynamic","defaultCurrency":"CNY"}. The fix comparescurrencyMode === 'fixed', so both shapes fall to the tenant default. The relay supplies no default of its own.Every reader of
sourceFieldMeta().defaultCurrencyin the package: one. It isenrichResultColumns(analytics-service.ts, the currency chain). The live and draft-preview paths ofqueryDatasetboth reach it, and the same loop covers__comparecolumns. A grep oversrc/fordefaultCurrencyand forcurrencfinds nothing else:sourceFieldMeta's other readers take only.type(the compile gate atregisterDataset, the time-dimension type,measureResultType) ormax(percentScaleOf).label.AnalyticsService.queryand its SQL echo) never writes a columncurrency. The suite pins that as measured.The fix
plugin.ts: the relay answersdefaultCurrencyonly whencurrencyConfig.currencyMode === 'fixed', andundefinedotherwise. The rule is decided at the one producer, so the one reader inherits it. An explicit measurecurrencystill wins.defaultCurrency, so no second key was added. The publicAnalyticsServiceConfig.sourceFieldMetareturn type is unchanged. Its TSDoc now says whatdefaultCurrencymeans there (the field's FIXED currency) and what a host writing its ownsourceFieldMetamust answer.packages/specchange and no objectui change.Tests
New
packages/services/service-analytics/src/__tests__/currency-mode-relay.test.ts, 19 cases. Each case runs end to end through the plugin's relay, the real engine and driver, and thequeryDatasetresult, under both registration shapes. Each asserts the live and the preview path. The cases:Before the fix: 5 failed, 14 passed. After: 19 passed. The failures before the fix were the 5 bold cells above:
Ablation. Every negative pin was ablated through
node scripts/ablation-replace.mjsin WRAP mode, after the fix was committed. Each mutation was proven on disk: the anchor count went 1 to 0 and the blob changed. Each restore was proven too: the blob equalsHEADandgit diff HEADis empty.plugin.ts: the pre-fix relay, which passesdefaultCurrencywhenever presentplugin.ts: a relay that fills in the schema'sCNYitself and ignores modeanalytics-service.ts: the cube face writes acurrencyon its columnsanalytics-service.ts: every measure treated as monetaryLeg C's first attempt did not run. Its replacement text still contained the anchor, so
ablation-replacerefused the leg before running anything ("the anchor count moved 1 -> 1"), and the file was restored. It was re-run with a replacement that consumes the anchor, and that run is the one in the table.Package suite on the merged head
131de3a37a:pnpm --filter @objectstack/service-analytics test, 125 files / 2905 tests passed.pnpm --filter @objectstack/service-analytics run typecheckexits 0.tsc --listFilesincludes the new test file (1 hit; the controlplugin.tsalso has 1).Changeset
.changeset/20091-analytics-currency-mode.md:@objectstack/service-analytics, patch, with aClause-②: noline. It is a fix to a released package that adds or removes no authorable key, export or type member.check-changeset-no-major --base origin/mainexits 0, and so doescheck-adr-0087-registration --base origin/main: the changeset is not declared breaking, so no ADR-0087 marker is owed.Acceptance notes
grep -ci currencon it gives 0, and the controldatetimegives 73. This PR corrects the label on the lines it rewrites (plugin.tsrelay comment,analytics-service.tsTSDoc and chain comment). It does not touch the same label inpreview-column-enrichment.test.ts(the header table and two test names). No carrier: noted, not filed.{ currencyMode: 'fixed' }with no code differs by registration door. Not measured, a source read. A raw registration relaysundefined, so the column gets the tenant default. A parsed one relays the spec's defaultedCNY, which the spec's own refinement treats as the field's currency in fixed mode. The relay follows the registered shape on purpose. Supplying the spec default here would be a second copy of it.sourceFieldMetamust apply the same rule. A type cannot express it, so the TSDoc and the changeset state it.Local verification (head
131de3a37a, after theorigin/mainmerge)node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 61 commands from this diff, and all 61 exit 0.--ran(each command recorded with its exit code) answers: "61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN".check:engine-double-contract,check:objectql-double-limit,check:query-options-erasure,check:type-check-coverage,check:type-check-debtandcheck:where-matcher.check:dual-build-cjs-loadsandcheck:type-check-debtfirst answeredPREREQUISITE NOT MET(exit 3), which is not a measurement. Both exit 0 afterturbo run build --filter='./packages/*' --filter='./packages/*/*'(72/72 tasks).GITHUB_TOKEN=… node scripts/check-issue-citations.mjsexits 0: 5 citations, 3 resolve, 2 are cross-repo.pnpm lintover the whole repo is CI's job):eslint --no-inline-config --format jsonon the 3 touched TypeScript files exits 0. The JSON reports 3 files, with 0 errors and 0 warnings.eslint --print-configputs all 3 files in the linted population: 6 rules each on the two sources, and 5 on the test file.eslint.config.mjsenables no type-aware linting (noparserOptions.project), and its local rules are scope-based and per-file. A diff to these files therefore cannot change a verdict on any untouched file.Generated by Claude Code