Repository navigation
fix(i18n): a dynamic-mode currency field shows the tenant currency, not its defaultCurrency (#10422) - #10461
Merged
objectstack-fleet[bot] merged 4 commits intoSep 25, 2026
Conversation
…hows the tenant currency `currencyConfig.defaultCurrency` is the field's currency only under `currencyMode: 'fixed'`. A dynamic config, and a config with no mode (the spec's default is dynamic), falls through to the legacy top-level `defaultCurrency`, then the tenant default. The parameter type gains `currencyMode`. The truth table is pinned beside the resolver. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
Each face that resolves a currency through `resolveFieldCurrency` (list cell, field widget, metric tile, detail summary chip, gantt tooltip, grid summary footer) gets a row: a dynamic field and a spec-parsed empty config read what a field with no `currencyConfig` reads, and a fixed field keeps its code. Two fields tests whose mode-less fixtures stood for "fixed" now say `currencyMode: 'fixed'`. The objectui#10354 changeset's dynamic-mode sentence described the resolver this change replaces; its body now says a dynamic field keeps the tenant currency. Its front matter is byte-identical. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
…de pin Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
…escape The editor materialized the escape into a raw U+00A0 byte, which `no-irregular-whitespace` refuses. Same regex, same match. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
This was referenced Sep 25, 2026
objectstack-fleet
Bot
deleted the
claude/issue-10422-currency-mode-dynamic-tenant-default
branch
September 25, 2026 03:47
This was referenced Sep 25, 2026
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Sep 28, 2026
…rency only under currencyMode 'fixed' (objectstack-ai#20091) (objectstack-ai#20119) Fixes objectstack-ai#20091 Clause-②: no ## What was wrong `AnalyticsServicePlugin` passes each source field's metadata to the service through `sourceFieldMeta`. That hook passed on `currencyConfig.defaultCurrency` without reading `currencyConfig.currencyMode`. `queryDataset` resolves a monetary measure column's `currency` in this order: the measure's own `currency`, then that relayed value, then `ExecutionContext.currency`. So a `dynamic` field's `defaultCurrency` showed on the dataset result where the tenant currency belonged. The spec says otherwise: - `CurrencyConfigSchema` defaults `currencyMode` to `'dynamic'`. Its own precision refinement treats `defaultCurrency` as the field's one currency only under `'fixed'`. - The `FieldSchema` guidance says a fixed currency is `currencyConfig: { currencyMode: 'fixed', defaultCurrency: … }`, and "A field without one uses the tenant default at runtime". objectstack-ai/objectui#10461 changed the renderer's `resolveFieldCurrency` to read `defaultCurrency` only under `'fixed'`. The renderer reads an explicit column `currency` first, though, so the analytics faces kept overriding that rule. ## Measured before the fix (commit `9a98c59b44`: base `fa00ebf447` plus the new test file only), and after `AnalyticsServicePlugin` booted over a real `ObjectQL` engine on `SqliteWasmDriver`, with the object registered on the engine. `ExecutionContext.currency = 'USD'`, and every authored `defaultCurrency` is `EUR`. Each cell is the result column's `currency` on the live path / the draft-preview path of `queryDataset`: | case | raw registration, before | parsed registration (`ObjectSchema.parse`), before | after the fix (both registrations) | |:---|:---|:---|:---| | `{ currencyMode: 'fixed', defaultCurrency: 'EUR' }` | EUR / EUR | EUR / EUR | EUR / EUR | | `{ currencyMode: 'dynamic', defaultCurrency: 'EUR' }` | **EUR / EUR** | **EUR / EUR** | USD / USD | | `{ defaultCurrency: 'EUR' }` (no mode) | **EUR / EUR** | **EUR / EUR** | USD / USD | | `{}` (parses to `dynamic` + the schema's `CNY`) | USD / USD | **CNY / CNY** | USD / USD | | currency field, no `currencyConfig` | USD / USD | USD / USD | USD / USD | | measure `currency: 'JPY'` over the dynamic field | JPY / JPY | JPY / JPY | JPY / JPY | | measure `currency: 'JPY'` over the fixed field | JPY / JPY | JPY / JPY | JPY / JPY | | non-monetary control (`number` field) | none / none | none / none | none / none | Bold cells break the spec rule. The "before" columns are also in the message of commit `9a98c59b44`. The "after" column was measured on head `131de3a37a` with the same probe. **What "no mode" looks like at the relay.** `SchemaRegistry.registerObject` stores what it is handed and does not parse. A raw `registerObject` call therefore arrives with no `currencyMode` key at all. The declared-stack path parses first, which fills in `currencyMode: 'dynamic'`, and fills in `defaultCurrency: 'CNY'` when the author wrote none. Measured with `ObjectSchema.parse`: `{ defaultCurrency: 'EUR' }` parses to `{"precision":2,"currencyMode":"dynamic","defaultCurrency":"EUR"}`, and `{}` parses to `{"precision":2,"currencyMode":"dynamic","defaultCurrency":"CNY"}`. The fix compares `currencyMode === 'fixed'`, so both shapes fall to the tenant default. The relay supplies no default of its own. **Every reader of `sourceFieldMeta().defaultCurrency` in the package: one.** It is `enrichResultColumns` (`analytics-service.ts`, the currency chain). The live and draft-preview paths of `queryDataset` both reach it, and the same loop covers `__compare` columns. A grep over `src/` for `defaultCurrency` and for `currenc` finds nothing else: - `sourceFieldMeta`'s other readers take only `.type` (the compile gate at `registerDataset`, the time-dimension type, `measureResultType`) or `max` (`percentScaleOf`). - Dimension columns get only a `label`. - Drill metadata and dimension value labels carry no currency. - The package has no export face. - The cube face (`AnalyticsService.query` and its SQL echo) never writes a column `currency`. The suite pins that as measured. ## The fix - `plugin.ts`: the relay answers `defaultCurrency` only when `currencyConfig.currencyMode === 'fixed'`, and `undefined` otherwise. The rule is decided at the one producer, so the one reader inherits it. An explicit measure `currency` still wins. - No reader needs the raw `defaultCurrency`, so no second key was added. The public `AnalyticsServiceConfig.sourceFieldMeta` return type is unchanged. Its TSDoc now says what `defaultCurrency` means there (the field's FIXED currency) and what a host writing its own `sourceFieldMeta` must answer. - No `packages/spec` change 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 the `queryDataset` result, under both registration shapes. Each asserts the live and the preview path. The cases: - fixed, dynamic, no mode, empty config, no config; - an explicit measure currency over a dynamic field and over a fixed one; - a non-monetary control; - two fixture controls: the shape really differs, and the live rows really came from the driver; - the cube-face pin. Before the fix: 5 failed, 14 passed. After: 19 passed. The failures before the fix were the 5 bold cells above: ```text AssertionError: expected 'EUR' to be 'USD' (dynamic, raw + parsed; no mode, raw + parsed) AssertionError: expected 'CNY' to be 'USD' (empty config, parsed) Tests 5 failed | 14 passed (19) ``` **Ablation.** Every negative pin was ablated through `node scripts/ablation-replace.mjs` in 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 equals `HEAD` and `git diff HEAD` is empty. | leg | mutation | red | |:---|:---|:---| | A | `plugin.ts`: the pre-fix relay, which passes `defaultCurrency` whenever present | 5 failed / 14 passed: dynamic ×2, no mode ×2, empty config (parsed) | | B | `plugin.ts`: a relay that fills in the schema's `CNY` itself and ignores mode | 8 failed / 11 passed: dynamic ×2, no mode ×2, empty config ×2, no config ×2 | | C | `analytics-service.ts`: the cube face writes a `currency` on its columns | 13 failed / 6 passed, including the cube-face pin | | D | `analytics-service.ts`: every measure treated as monetary | 2 failed / 17 passed: the non-monetary control ×2 | | E | test fixture: the raw shape is parsed too | 1 failed / 18 passed: the shape control | Leg C's first attempt did not run. Its replacement text still contained the anchor, so `ablation-replace` refused 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 typecheck` exits 0. `tsc --listFiles` includes the new test file (1 hit; the control `plugin.ts` also has 1). ## Changeset `.changeset/20091-analytics-currency-mode.md`: `@objectstack/service-analytics`, **patch**, with a `Clause-②: no` line. 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/main` exits 0, and so does `check-adr-0087-registration --base origin/main`: the changeset is not declared breaking, so no ADR-0087 marker is owed. ## Acceptance notes - **The code labels this chain "ADR-0053", but ADR-0053 says nothing about currency.** ADR-0053 is date/datetime semantics: `grep -ci currenc` on it gives 0, and the control `datetime` gives 73. This PR corrects the label on the lines it rewrites (`plugin.ts` relay comment, `analytics-service.ts` TSDoc and chain comment). It does not touch the same label in `preview-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 relays `undefined`, so the column gets the tenant default. A parsed one relays the spec's defaulted `CNY`, 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. - **A host that writes its own `sourceFieldMeta`** must apply the same rule. A type cannot express it, so the TSDoc and the changeset state it. ## Local verification (head `131de3a37a`, after the `origin/main` merge) - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 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". - The derivation added six families to the dispatch-time list: `check:engine-double-contract`, `check:objectql-double-limit`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. - `check:dual-build-cjs-loads` and `check:type-check-debt` first answered `PREREQUISITE NOT MET` (exit 3), which is not a measurement. Both exit 0 after `turbo run build --filter='./packages/*' --filter='./packages/*/*'` (72/72 tasks). - `GITHUB_TOKEN=… node scripts/check-issue-citations.mjs` exits 0: 5 citations, 3 resolve, 2 are cross-repo. - **Lint, narrowed to the touched files** (`pnpm lint` over the whole repo is CI's job): - `eslint --no-inline-config --format json` on the 3 touched TypeScript files exits 0. The JSON reports 3 files, with 0 errors and 0 warnings. - `eslint --print-config` puts all 3 files in the linted population: 6 rules each on the two sources, and 5 on the test file. - Linting only these files misses nothing. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.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](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #10422
Clause-②: yes
A currency field in
dynamicmode now shows the tenant's currency on every face, not itscurrencyConfig.defaultCurrency.resolveFieldCurrencyin@object-ui/i18nreadscurrencyConfig.defaultCurrencyonly whencurrencyModeis'fixed'. Its parameter type gainscurrencyConfig.currencyMode?: 'dynamic' | 'fixed', the spec's enum. No other export changes.In a USD tenant, a field declaring
currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'EUR' }read€3,456; it now reads$3,456. An emptycurrencyConfig, which the spec parses to a dynamic CNY config, readCN¥; it now reads$. Afixedfield renders as before.The contract, quoted from the installed spec
@objectstack/spec17.4.0, the version in objectui's lockfile,CurrencyConfigSchema:currencyMode: z.enum(['dynamic', 'fixed']).default('dynamic').describe('Currency mode: dynamic (user selectable) or fixed (single currency)')defaultCurrency: z.string().length(3).default('CNY').describe('Default or fixed currency code (ISO 4217, e.g., USD, CNY, EUR)')FieldSchemaguidance for the keycurrency, which it refuses: "currencyis not a field key — a fixed currency is declared ascurrencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' }. A field without one uses the tenant default at runtime."Measured by parsing through that installed spec from this worktree:
CurrencyConfigSchema.parse({}){"precision":2,"currencyMode":"dynamic","defaultCurrency":"CNY"}CurrencyConfigSchema.parse({ defaultCurrency: 'EUR' }){"precision":2,"currencyMode":"dynamic","defaultCurrency":"EUR"}, so an absent mode isdynamicCurrencyConfigSchema.parse({ currencyMode: 'fixed', defaultCurrency: 'JPY' }){"currencyMode":"fixed","defaultCurrency":"JPY"}FieldSchemawith a top-leveldefaultCurrency: 'GBP'unrecognized_keysFieldSchemawith a top-levelcurrency: 'GBP'unrecognized_keys, with the guidance aboveThe precedence (tenant USD)
currencyFieldSchemakey, but the spec declarescurrencyon the metric widget and on dataset measures, and looser column configs carry it. Kept first: nothing in the spec says otherwise.currencyConfigwithcurrencyMode: 'fixed'defaultCurrencycurrencyConfigwithcurrencyMode: 'dynamic'FieldSchemaguidance: the tenant default. ⛔ NotdefaultCurrency.currencyConfigwith nocurrencyModedynamic(measured above).dynamic,CNY)CN¥.defaultCurrency6c3f018a8added it as the "canonical top-level" key, whichFieldSchemanow refuses as unrecognized. Kept in its place.localization.currency, ADR-0053undefinedTriage point 1 says a value carrying its own user-selected currency should win. Nothing carries one today: the spec deprecates
CurrencyValueSchema, and a currency value is a bare number. So there is nothing to implement. The docblock says where such a code would rank: ahead of the tenant default.Consumers: what each call site passes
currencyModereach the resolver?CurrencyCellRenderer(@object-ui/fields)resolveFieldCurrency(field as any, tenantCurrency)currencyConfigverbatim since objectui#10354.CurrencyFieldresolveFieldCurrency(currencyField, tenantCurrency)fieldpropObjectMetricWidgetresolveFieldCurrency(valueFieldDef, tenantCurrency)aggregate.fieldDetailViewresolveFieldCurrency({ ...objField, ...sectionField }, tenantCurrency)currencyConfigreplaces the object's as a whole.ObjectGanttresolveFieldCurrency(def as any, tenantCurrency)useColumnSummaryresolveFieldCurrency(column, tenantDefault)currencyConfig: meta?.currencyConfigverbatimGridFieldcomputed currency columnresolveFieldCurrency(undefined, tenantCurrency)ObjectGridmobile card amountresolveFieldCurrency(amountCol as any, tenantCurrency)No consumer needed a change. Every consumer that reads a
currencyConfigpasses it through whole.Pins
packages/i18n/src/__tests__/resolveFieldCurrency.test.ts): the truth table under a USD tenant. It covers fixed, dynamic, parsed-empty (parsed through the installed spec, and as a literal), no mode (the spec's default is read from the spec itself), dynamic with no tenant default, fixed with no code, an explicitcurrencyover both modes, the legacydefaultCurrencynext to each mode, and none. A control row feeds the showcase configs in objectstack at246314df, allfixedUSD (account,field-zoo,semantic-zoo), under a JPY tenant: they read USD.packages/fields/src/__tests__/currencyMode.faces-10422.test.tsx), metric tile (ObjectMetricWidget.currencyMode-10422.test.tsx), detail chip (summaryChip.currencyMode-10422.test.tsx), gantt tooltip (ObjectGantt.currencyMode-10422.test.tsx), and grid summary (useColumnSummary.currencyMode-10422.test.tsx). Each row asserts two things. A dynamic field, and a spec-parsed empty config, read exactly what a field with nocurrencyConfigreads on that face. That reference is itself pinned to the tenant$as absolute bytes. A fixed JPY field keeps¥. The grid's configured cells are held to the whole-def cell bygridFixedCurrency-10354.test.tsx, which stays green. Itsdynrow now reads$3,456on both sides.packages/fields/src/__tests__/resolveFieldCurrency.test.tsandCurrencyField.minorUnits.test.tsxused a mode-lesscurrencyConfigto stand for a fixed one. They now saycurrencyMode: 'fixed'. The re-export test also gains a dynamic row.Ablation (on committed code,
34795f4b4)The mutation was the mode read, removed with
ablation-replace.mjs. The replacement text:(config?.currencyMode === 'fixed' ? config.defaultCurrency : undefined)becameconfig?.defaultCurrency.x1 -> x0, replacementx0 -> x1, blobf192e57d0f84 -> af1b29163c2f. Inside the run,grep -cfound the mode read 0 times and the ablated line once.packages/i18n/src, so no build ordiststep applies.Tests 19 failed | 43 passed (62). Every dynamic and parsed-empty row went red, on the resolver and on all six faces, as did the resolver's no-mode, dynamic-with-no-tenant and legacy-with-dynamic rows. Every fixed and control row stayed green.f192e57d0f84equals the blob at HEAD, andgit diff HEADis empty.Reverse type check (at
8380484d6, after the closure build). I wrotecurrencyMode: 'fixd'into the dynamic row ofpackages/fields/src/__tests__/resolveFieldCurrency.test.ts. That file imports the resolver through@object-ui/fieldsfrom@object-ui/i18n's rebuiltdist(paths: {}).tsc -p tsconfig.test.jsonthen fails withTS2820: Type '"fixd"' is not assignable to type '"fixed" | "dynamic" | undefined'. Did you mean '"fixed"'?, and--listFilesOnlylists both test files. Restored by blob equality and an emptygit diff HEAD.Local verification
Final head:
593f65aa3. It differs from8380484d6by one line: a regex incurrencyMode.faces-10422.test.tsxnow spells U+00A0 as an escape instead of a raw byte. The same regex matches the same text.593f65aa3:gridFixedCurrency-10354, plus the two tests that readcurrency.tsas text (machineLocaleCensus-9909,CurrencyField.symbol): all passed;pnpm --filter @object-ui/fields type-check: exit 0;pnpm check:control-bytes: OK;pnpm check:new-line-citations: 0 new;node scripts/check-changeset-presence.mjs: 9 source files of 6 released packages, 1 changeset declared;pnpm check:changeset-claims: no pending changeset names a touched file, and the two bodies this PR adds or edits are self-consistent;check-changeset-no-major: exit 0;check-changeset-overwrite(report-only): it reports the intended objectui#10354 body edit;check:phantom-deps,check:vi-mock-specifiers,check:vi-mock-inherit,check:vi-mock-override-shape,check:test-path-roots,check:pending-changeset-literals: all exit 0.8380484d6, from the repo root:vitest run packages/i18n/ packages/fields/gave 257 files passed and 1 skipped, 4280 tests passed and 7 skipped.packages/plugin-dashboard/ packages/plugin-detail/ packages/plugin-gantt/gave 406 passed and 1 skipped, 3857 tests passed and 8 skipped.packages/plugin-grid/ran in two shards: 77 + 77 files, 687 + 786 tests passed.8380484d6, afterturbo run buildover the dependency closure of fields, plugin-dashboard, plugin-detail, plugin-gantt and plugin-grid (14 tasks, i18n included):type-checkexited 0 for@object-ui/i18n,fields,plugin-dashboard,plugin-detail,plugin-ganttandplugin-grid..ts/.tsxfiles: 0 errors. The 4 modified files keep their base counts exactly:no-explicit-anywarnings 3, 1, 1 and 0. The 5 new test files add 10no-explicit-anywarnings, the idiom of the suites they copy (theGanttViewstub,as anydata sources).The objectui#10354 changeset (bounded in-place fix)
Its "Behaviour change" paragraph said a
dynamicfield showsdefaultCurrencyon the grid, "becauseresolveFieldCurrencydoes not readcurrencyMode". That changeset is still pending, so this PR would publish it false at the next release. The paragraph now says a field whosecurrencyConfigis infixedmode shows its code, and adynamicfield still shows the tenant currency. The front matter is byte-identical to the base (od -c).The four conditions for an in-place fix:
This file is outside the claim's declared file surface. The report says so.
Acceptance notes
fixedcondition: theCurrencyCellRenderercomment inpackages/fields/src/index.tsx,CurrencyField.tsx, thecolumnCurrencydocblock inGridField.tsx, and abuildDefaultPageSchema.tscomment. They are now incomplete, not wrong about any key. Left alone, because they are outside this claim's file surface. Carrier: none named.Out of scope (reported to the seat, not fixed here)
ObjectGridmobile card amount and the dashboardObjectDataTablecell (buildFieldMetainrecordFields.tsx) never hand the resolver acurrencyConfig. Throwaway probes, deleted afterwards, measured both under a USD tenant with a fixed-JPY1234: the desktop grid read¥1,234, the mobile card read$1.2K, and the dashboard table cell read$1,234.skills/objectstack-data/rules/field-types.mdstates the resolution chain without the mode, and describesdynamicas a per-record{ value, currency }. The spec says a currency value is a bare number.Generated by Claude Code