Skip to content

fix(rest): export boolean cells follow the caller's language - #24998

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24945-export-boolean-locale
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24945-export-boolean-locale

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24945

Clause-②: no

What changes

GET /api/v1/data/:object/export wrote every boolean or toggle cell as the literal 是 / 否. The header row of the same file follows the caller's locale, so an Accept-Language: en-US caller got "Converted" and "Do Not Call" over Chinese cells. The cell now follows the same locale as the header.

  • packages/rest/src/export-format.ts
    • formatCellValue, formatRowCells and formatRowForJson take booleanWords beside timezone. The boolean / toggle branch writes booleanWords[0] / booleanWords[1].
    • The words have one source: templateText(locale).booleanWords in import-template.ts, the table the import template already uses (yes / no, or 是 / 否 for a zh* locale). The new ExportBooleanWords type is that table's own field type. No second table.
    • Without words, the formatter writes the pair templateText(undefined) answers (yes / no). That is the same answer the import template gives a request with no locale.
  • packages/rest/src/data-action-routes.ts, the export route
    • It resolves the request's i18n service once (resolveI18nService, then extractLocale(req, i18n)). That is the same read answerImportTemplate uses. It computes the words once per export from that locale.
    • It hands the same i18n service to translateMetaItem (its existing optional fifth argument). So the header translation and the words read one locale, from one service: Accept-Language, then ?locale=, then the service's default locale.
    • csv, xlsx and json all pass the words through.
  • packages/rest/src/rest-server.ts. This file is outside the claimed file surface. See Deviations.
    • The DataActionRouteHost literal gains resolveI18nService and extractLocale. The route had no locale in hand: the header's locale was resolved inside translateMetaItem and never left it.
    • rowsToCsv, the csv arm's serializer, lives here. It takes booleanWords after timezone and passes them to formatRowCells.
  • packages/rest/src/import-template.ts: one import line. buildFieldMetaMap / ExportFieldMeta now come from @objectstack/core, where they are defined, and no longer through export-format.ts's re-export. export-format.ts now imports templateText from here, and this keeps the two modules from importing each other.
  • No key, query parameter, accept set, header, column set or payload shape moves. Only the text of a boolean cell changes. The public declaration file gains nothing: in the built dist/index.d.ts, booleanWords, ExportBooleanWords, templateText and rowsToCsv each count 0 hits, and the control ExportFieldMeta counts 3.

Readings on the mechanism assumptions

All readings come from the real export and import routes, on a real ObjectQL engine and protocol with a sqlite :memory: driver (export-boolean-locale.test.ts).

  1. Main writes 是 / 否 to an English caller, in every format. At base 78ab6474ca, an en-US csv export reads header ID,Full Name,Converted,Do Not Call, then row 1,Wren Halvorsen,是,否. xlsx gives the same cells. json gives "是" / "否" as strings: the json arm runs the same branch through formatRowForJson, so it never kept raw true / false. With this PR, json writes the caller's words as strings. It does not switch to raw booleans, because that would move a payload shape.
  2. zh-CN and no locale.
    • A zh-CN caller keeps 是 / 否, under the Chinese header.
    • templateText(undefined) answers the English table, so a request with no locale gets yes / no.
    • The header with no locale is the authored label, untranslated. It agrees with the cells only when the labels are authored in English (the pinned fixture). A deployment whose i18n service declares a default locale resolves a header-less request to that default for both halves. This is pinned for a zh-CN default and for an en default.
  3. Re-import, both languages. The importer (IMPORT_BOOLEAN_TRUE_TOKENS / IMPORT_BOOLEAN_FALSE_TOKENS in packages/spec) reads yes / no and 是 / 否. Each language's csv and xlsx export, posted to POST /api/v1/data/:object/import into a fresh store, reads back as [true, false] / [false, true]. All four pins are green.
  4. feat(rest): 列表导出支持 XLSX + 类型感知字段格式化(lookup/user/select/boolean/date) #2481 and 导出/导入文件名与多语言优化:显示名+视图名+时间戳命名、模板全中文、导出↔导入选项标签闭环 #2916. feat(rest): 列表导出支持 XLSX + 类型感知字段格式化(lookup/user/select/boolean/date) #2481 (the feature that added the formatter) lists "boolean: 是 / 否" in its plan. It was written before the export resolved any locale, it has no comments, and its author is a contributor. 导出/导入文件名与多语言优化:显示名+视图名+时间戳命名、模板全中文、导出↔导入选项标签闭环 #2916 made export and import follow the caller's language and does not mention booleans. Neither is a maintainer ruling that booleans are 是 / 否 for every locale.

Evidence

  • Red on main. At d913467d6e, which is the new test file alone on base 78ab6474ca: Tests 5 failed | 6 passed (11). The red pins are en csv, the xlsx pair, the json pair, the default-locale pin (its en half) and the no-i18n pin. The 6 green ones are the zh csv control, ?locale=zh-CN, and the four re-import pins: main's 是 / 否 file re-imports.
  • Green. export-boolean-locale.test.ts: Tests 11 passed (11). The 11 affected export / import files: Tests 620 passed (620).
  • Ablation A: the locale handed to the words. It replaced templateText(this.extractLocale(req, i18n)).booleanWords with templateText(undefined).booleanWords through scripts/ablation-replace.mjs, on data-action-routes.ts (anchor 1 → 0, blob 53f7b7e9 → 5c3ec476).
    • Result: Tests 5 failed | 6 passed (11). Every zh pin went red: csv, the xlsx and json pairs, ?locale=, and the default-locale pin.
    • The en pin stayed green. That is the direction the code predicts: with the locale dropped, the cells fall to templateText(undefined), which is the English pair. So the en pin's red reading is the main-red above, not this ablation.
    • Restored: blob == HEAD 53f7b7e9, and git diff HEAD is empty.
  • Ablation B: one i18n service for both halves. It replaced this.extractLocale(req, i18n) with this.extractLocale(req, undefined) (blob 53f7b7e9 → 8299404a).
    • Result: Tests 1 failed | 10 passed (11). The red pin is the default-locale one: a zh-CN-default deployment wrote the header 编号,姓名,已转化,请勿致电 over yes / no cells.
    • Restored: blob == HEAD 53f7b7e9, and git diff HEAD is empty.
  • Both ablations exercise this package's own src/, reached by relative import from the test. No dist/ rebuild was needed.

Fixture triage

The existing pins on 是 / 否 named no locale. Each was triaged by its fixture's language, so its header and cells keep one language:

  • Chinese fixtures now name a zh-CN caller. These are export-integration.test.ts, rest.test.ts's mocked export suite, and import-template-route.test.ts's exportOnce.
    • The last of these holds the recorded export bytes (csv, json, xlsx by sha256), and it stays byte-identical with that caller.
    • So a Chinese caller's file is exactly what it was before this PR.
  • English fixture. export-json-fields.test.ts (labels ID / Title, no locale) now expects yes / no.

Gates (final head 3d3bafba28)

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 68 commands. All 68 exited 0.
  • The 10 families under "Declared WIDE population" also exited 0. These are check:init-service-contract, live-db-isolation, meta-type-normalized, optional-error-sink, resume-authority-declared, runner-env-posture, settings-bind-window, startup-registry-verdict, verify-stand-in and wildcard-fallthrough.
  • The --ran reconciliation: ✓ dispatch-gates --ran: 68 derived famil(ies) accounted for — 68 run, 0 NOT-MEASURED (a DERIVED zero — all 68 recorded an exit code and none of them is 3).
  • pnpm lint (eslint . --no-inline-config): exit 0, no findings printed.
  • pnpm --filter @objectstack/rest exec vitest run --project local: Test Files 307 passed (307), Tests 6165 passed | 326 skipped (6491).
  • pnpm --filter @objectstack/rest typecheck: exit 0. The test layer compiles the new file: --listFiles counts export-boolean-locale.test.ts once.
  • An earlier head red check:query-options-erasure (test surface 236 → 238). The cause was two as any casts on the re-import pins' engine.find options. They are typed now, and the gate is green at the final head.

Deviations

  • File surface. The claim's surface did not list packages/rest/src/rest-server.ts. The fix needs it twice: once for the host wiring, because the route had no locale, and once for rowsToCsv, because the csv serializer lives there. The edits come to two host entries, one import name and the rowsToCsv parameter with its doc.

Acceptance notes

  • No i18n service and no locale. The cells read yes / no while the header shows the authored labels. A deployment with Chinese-authored labels, no i18n service and a header-less caller would show a Chinese header over English words. The import template behaves the same way in that case (templateText(undefined)). A deployment that registers the i18n service gets its default locale for both halves.
  • json carries strings. json boolean cells are display strings ("yes" / "是"), as they were ("是") before this PR. That is the formatting layer's existing design, unchanged here.

Generated by Claude Code

Red on main: an Accept-Language en-US caller gets the English header row
over boolean cells written as the literal 是 / 否, in csv, xlsx and json.

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
GET /data/:object/export wrote every boolean / toggle cell as the literal
是 / 否, while the header row of the same file followed the caller's
locale. The route now resolves the request's i18n service once, translates
the header with it, and writes the boolean cells in
templateText(locale).booleanWords for the locale extractLocale reads off
that same service: yes / no, or 是 / 否 for a zh* locale. csv, xlsx and
json alike. The importer reads both languages back.

Existing pins on 是 / 否 named no locale: the Chinese fixtures now name a
zh-CN caller (their recorded bytes are unchanged); the English fixture
expects yes / no.

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
check:query-options-erasure counts an options bag cast to any; the
re-import pins pass a typed orderBy instead.

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 15 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/rest/src/import-template.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via getBookTree (sdk, the bare tail of client method meta.getBookTree, bound to GET /api/v1/meta/book/:name/tree), meta.getBookTree (sdk, the route ledger binds it to GET /api/v1/meta/book/:name/tree, selected by route anchor /book/:name/tree))
  • content/docs/protocol/kernel/i18n-standard.mdx (via extractLocale (symbol, a field of interface DataActionRouteHost), extractLocale (literal, a string literal in extractLocale))
  • content/docs/ui/apps.mdx (via /book/:name/tree (route, bridged from symbol extractLocale — its route source's handler names it))
  • content/docs/ui/forms.mdx (via /forms/:slug (route, bridged from symbol extractLocale — its route source's handler names it; bridged from symbol resolveI18nService — its route source's handler names it))
  • content/docs/ui/public-data-collection.mdx (via /forms/:slug (route, bridged from symbol extractLocale — its route source's handler names it; bridged from symbol resolveI18nService — its route source's handler names it))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx (via /book/:name/tree (route, bridged from symbol extractLocale — its route source's handler names it))
  • content/docs/releases/v17/17-5.mdx (via /book/:name/tree (route, bridged from symbol extractLocale — its route source's handler names it))
  • content/docs/releases/v17/17-6.mdx (via /forms/:slug (route, bridged from symbol extractLocale — its route source's handler names it; bridged from symbol resolveI18nService — its route source's handler names it))
  • content/docs/releases/v17/17-7.mdx (via /forms/:slug (route, bridged from symbol extractLocale — its route source's handler names it; bridged from symbol resolveI18nService — its route source's handler names it))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/rest/src/import-template.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 19 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4b8f597ba357ba45c7b4b4a4504c78b2a079f123 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e003539fddf28c6a73ab9c8f1303ce7a7e725f55 — the merge of head 3d3bafba2842e08acbfc9de0409d368a1e30ba9e into base 4b8f597ba357ba45c7b4b4a4504c78b2a079f123, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e003539fddf28c6a73ab9c8f1303ce7a7e725f55 && git checkout e003539fddf28c6a73ab9c8f1303ce7a7e725f55
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4b8f597ba357ba45c7b4b4a4504c78b2a079f123 3d3bafba2842e08acbfc9de0409d368a1e30ba9e && git checkout -B drift-repro 4b8f597ba357ba45c7b4b4a4504c78b2a079f123 && git merge --no-ff 3d3bafba2842e08acbfc9de0409d368a1e30ba9e

node scripts/docs-audit/affected-docs.mjs --json 4b8f597ba357ba45c7b4b4a4504c78b2a079f123

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4b8f597ba357ba45c7b4b4a4504c78b2a079f123 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT

domain:cli seat 2 (#22648) · os-justin · session session_01LmjU2x1faLZ39DVTFyVca6 · 2026-10-20T23:05Z · head 3d3bafba2842e08acbfc9de0409d368a1e30ba9e · claim 6302401259 → report 6303744014. No contract face (Clause-②: no: only a boolean cell's words follow the caller's locale), so no review of record is owed.

Check Reading
Shape Draft, base main. First line Fixes #24945; Clause-②: no line-initial. Assignee os-justin = the card's. One changeset, @objectstack/rest: patch.
Surface 10 files, +367 / −30: export-format.ts, the export route in data-action-routes.ts, import-template.ts (an import moved to @objectstack/core so the two modules do not import each other), rest-server.ts, one new pin file, four existing pins, and the changeset. rest-server.ts was outside the claim. This seat adds it to the surface: the route host gains resolveI18nService / extractLocale (both already on RestServer), and rowsToCsv gains an optional booleanWords. rowsToCsv is not exported from @objectstack/rest. Without it, the header and the cells could not read one locale. check-governed-merges.mjs --pr 24998: NOT governed.
Mechanism Read in the diff. The route resolves the i18n service once and reads extractLocale(req, i18n). It takes templateText(locale).booleanWords (import-template.ts, the one table), and hands the same service to translateMetaItem (its existing optional i18nService parameter), so the header and the cells cannot disagree. csv, xlsx and json all pass the words. The formatter's no-words default is templateText(undefined), the English pair, with no second literal table.
Evidence 5 of 11 pins red on main (1,Wren Halvorsen,是,否 under an English header). Each language's csv and xlsx re-imports as [true,false] / [false,true]. Two ablations (the locale hand-off; the i18n service handed to extractLocale) each red the pins they name; both restored hash-equal. The Chinese fixtures name a zh-CN caller, and the recorded template-route bytes stay identical. The rest package in full: 307 files / 6165 tests.
Gates Local: 68/68 derived families and the 10 Declared WIDE population families exit 0, 0 UNRUN; pnpm lint exit 0. Check-runs on this head (38, latest per name), read at this act: 14 success, 7 skipped, 17 in_progress. Landing waits on all of them.

Accepted deviations:

Acceptance notes (one line each):

  • With no i18n service and no locale, the header shows the authored labels while the cells read yes / no. The import template behaves the same way, and a deployment whose i18n service declares a default resolves both halves to it.
  • json boolean cells remain display strings, now in the caller's words, as the formatting layer's design has them.

Landing: through the merge queue by this seat once every check on this head reads success (or an expected skip).


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 20, 2026 23:31
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 20, 2026 23:31
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit 86d2360 Oct 20, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24945-export-boolean-locale branch October 20, 2026 23:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

rest: list export writes every boolean cell as the literal 是 / 否, whatever the caller's language

2 participants