Skip to content

Commit 481ac62

Browse files
refactor(objectql): move the validation-message i18n helpers out of engine.ts into engine-validation-messages.ts (#25522 cut 1) (#25615)
Part of #25522 Clause-②: no Metadata-protocol: none Ruling-ref: 6348891160 Cut 1 of the `packages/objectql/src/engine.ts` split (#24586 ruling A, recorded in `6270097840`). The claim is `6382124735`. Three `ObjectQL` method bodies move unedited into a new module, `packages/objectql/src/engine-validation-messages.ts`: the validation-message localisation helpers `validationMessageContext`, `negotiatedMessageLocale` and `objectDisplayLabel` (all private). `setI18nService`, the public setter of the field they read, stays in the class: it is a two-line field write, and moving it would grow the class rather than shrink it. It is move-only, and the card stays open for the later cuts. Branch `claude/issue-25522-objectql-engine-cut-1`, cut from `origin/main` at `e649c2d91e`; `main` has not moved since. **Serial with the file's in-flight PRs.** At start, none of the 12 open PRs touched `packages/objectql/**` (each PR's file list read). Re-measured just before this PR was opened, on all 14 open PRs: none touches `packages/objectql/**`. ## Why this area for cut 1 The fresh seam table below has six areas with zero or one line from the last 30 days. This one is the only one that meets every series limit and has no path reader outside `packages/objectql`: - **Roll-up summaries** (0 lines in 30 days) was the first candidate. Two things rule it out for a first cut. `recomputeSummaries` has an engine write (`this.update(`) and hands `this` to `aggregateSummaryValue`, so it would have to stay. And every `summaryOperations` mention in `engine.ts` sits in `buildSummaryIndex`, which the liveness ledger `packages/spec/liveness/field.json` cites as evidence by path, so moving it pulls a `packages/spec` edit into the slice. - **Lifecycle init and health** and **Datasource routing** carry the inferred-return members `init` and `setDatasourceMapping`, which must stay. - **Datasource registry** has three `this.FIELD.delete/insert/update(` sites (the `UNTYPED_RECEIVERS` limit). - **Validation-message i18n** (122 lines, 0 from the last 30 days): no public member moves, no inferred return type, no write call of any kind, no static, and its only readers are prose mentions. ## The shape (#23050 shape B, as #25222, #25223, #25224 and #25467 apply it) - The class keeps each method with its name, signature, docblock and position, because tsup emits members with their JSDoc into the `.d.ts`. Each body is now one host literal plus `fn.call(host, …)`, marked `[#25522 cut 1]`. `objectDisplayLabel` reads no member and is called directly. - `ValidationMessageHost` is new. It types every member as the class member itself (`ObjectQL['i18nService']`, `ObjectQL['negotiatedMessageLocale']`). Each moved function types its `this` as the `Pick` of the host over exactly the members its body reads, and the stub annotates its literal as the `ThisParameterType` of the moved function. No cast, and no member becomes public or protected. - `negotiatedMessageLocale` goes in as a late-bound closure (`(...a) => this.negotiatedMessageLocale(...a)`), so a spy or double set on the instance is still the one `validationMessageContext` reaches. - The one field read, `i18nService`, goes as a getter. That includes the read inside the `translate` closure, which runs after `validationMessageContext` has returned and still sees whatever service is installed at call time, as before. - **Imports.** The module imports `ObjectQL` as a TYPE only, so there is no module cycle at run time. It imports `objectLabelKey` and `resolveBundleLocale` from `@objectstack/spec/system`, the specifier `engine.ts` used. Those two names had no other reader in `engine.ts`, so they leave its import list: that is the only edit outside the class besides the new module import. Nothing re-exports the module. ## The published `.d.ts` does not change Under the series bar (ruling `6348891160`). Base was a `turbo run build --filter='@objectstack/objectql...'` at `e649c2d91e` (14/14 tasks). Head was `pnpm --filter @objectstack/objectql build` at `328b5b194e` against the same dependency tree. `@objectstack/objectql` emits six declaration files: `index`, `core` and a shared chunk `util-ByiLm3B6` (where `declare class ObjectQL` lives), each as `.d.ts` (CJS) and `.d.mts` (ESM). The chunk name is the same at base and head. All six are **byte-identical**, and the raw diff of each is 0 lines: | file | raw sha256, base = head | normalised sha256, base = head | |---|---|---| | `index.d.ts` | `9ca22071717e3f761198115fa54a143df4151a52950d826a10677420dcad16ae` | `9ca22071717e3f761198115fa54a143df4151a52950d826a10677420dcad16ae` | | `index.d.mts` | `69dd1cfe3875024f434b2d7defce771ebc8c7e48a05be7c946fa6ecdbd912b40` | `69dd1cfe3875024f434b2d7defce771ebc8c7e48a05be7c946fa6ecdbd912b40` | | `core.d.ts` | `d27b72bde2eec9a47c7dd3c104976321533cf397452ca9f84491a455ec28e73b` | `d27b72bde2eec9a47c7dd3c104976321533cf397452ca9f84491a455ec28e73b` | | `core.d.mts` | `5a0cca505296b678b31c742c285e028fe434f5467bb8d27e052e470ce0e09652` | `5a0cca505296b678b31c742c285e028fe434f5467bb8d27e052e470ce0e09652` | | `util-ByiLm3B6.d.ts` and `.d.mts` | `1742a9cb029654d1ca4e8e392e98001eec85963987f516b1b76f672ecd08e5a9` | `1742a9cb029654d1ca4e8e392e98001eec85963987f516b1b76f672ecd08e5a9` | The normaliser, verbatim from the ruling, run on each file: ```sh node -e 'const s=require("fs").readFileSync(process.argv[1],"utf8");process.stdout.write(s.replace(/"[^"\n]*"(?:\s*\|\s*"[^"\n]*")+/g,r=>r.split(/\s*\|\s*/).sort().join(" | ")))' dist/util-ByiLm3B6.d.ts | sha256sum ``` - **Normalised = raw on every file, and this is expected.** This package's declarations print string literals in single quotes: the chunk holds 23 single-quoted literal unions and 0 double-quoted ones. So the ruling's normaliser, which matches only double quotes, changes nothing here. This cut does not depend on it, because the raw bytes are identical. A later cut of this file would, though; see "For the next dispatch" below. - **Positive control** on a copy of the head `util-ByiLm3B6.d.ts`: line 7261 `private objectDisplayLabel;` made public (`objectDisplayLabel;`, one line differs) gives raw = normalised `270f913f734273e380af6ab964e8b15206aa7c511892dea58cc8f6fd9adf5ef7`, which differs from `1742a9cb…`. - **Export lists**, loaded at runtime from the built bundles (`import()` of `dist/*.mjs`, `require` of `dist/*.js`), are identical at base and head: `.` exports 182 names under ESM and 182 under CJS, and `./core` exports 54 and 54. The sorted lists hash `881616cc179189098182bd6b8a95701d9922f9677b786bc12e2d31c1adb0adaa` (`.`) and `393e806906e876c2e020aa96617d54050a35ae63cd483b67d8e968b7b5390f49` (`./core`) at both ends. No changeset: `skip-changeset`, per ruling `6348891160` item 4. ## Proof of move-only - **Bodies, by AST token** (recursive `getChildren` leaves): each moved function's parameters (less the added `this`), return type and body have the same token sequence as the base method, and each body line is the base line with two leading spaces removed. 371 tokens compared, 0 differ. Negative control: one changed token in `negotiatedMessageLocale` makes the check report a difference. - **Class side**: the class has 317 members at base and head. The 314 members that did not move are byte-identical to base. Each of the 3 stubs keeps its docblock and signature byte-identical. - **Top level of `engine.ts`**: 232 of the 233 statements outside the class are byte-identical. The other is the `@objectstack/spec/system` import, less `objectLabelKey` and `resolveBundleLocale`. The one new statement is the import of the new module. - **Size**: +135 / -39 over 2 files, under the 3,000-line manual-merge line. `engine.ts` goes from 23,903 to 23,881 lines. Each stub keeps its docblock and carries its host literal, so the file shrinks by less than the bodies' length. `engine-validation-messages.ts` has 118 lines. - **The tests reach the moved body**: a one-off mutation of the moved `negotiatedMessageLocale` (its `resolveBundleLocale(...)` return replaced by `return requested;`, written with `scripts/ablation-replace.mjs`) turns `engine-validation-locale-negotiation.test.ts` red, 3 failed / 3 passed. It was restored to the HEAD blob `5d9194fa75bb` with `git diff HEAD` empty, and no test file was kept. ## Readers of the moved lines - **ADR anchors.** `scripts/adr-anchors/packages__objectql__src__engine.ts.json` carries one paragraph, on the transaction seam (ADR-0057, ADR-0067, ADR-0119), and none of it describes a moved body. The moved bodies cite no ADR (the ADR-0053 Phase 2 reference is in `validationMessageContext`'s docblock, which stays on the stub). So the new module owes no anchor entry, and none is added. `check:adr-anchors`: 84 → 84 anchored files; decision-letter citations on the anchored surface 1,883 → 1,883; 52,465 citations across 6,583 files at both ends. - **Path readers of these symbols** (`git grep` repo-wide): none pins `engine.ts#validationMessageContext`, `#negotiatedMessageLocale` or `#objectDisplayLabel`. The mentions are prose: `plugin-approvals/src/reader-locale-labels.ts` and `service-automation/src/engine.ts` (by method name, which still resolves), and `spec/src/system/translation.zod.ts` (`engine.ts`'s `setI18nService`, which did not move). `check-adr-symbol-anchors`: 2,384 anchors resolve at head. - `core-boundary.ratchet.test.ts`: the new module joins the `/core` closure and imports only `@objectstack/spec/system`. It passes. ## Seam table (fresh, `origin/main` `e649c2d91e`, 2026-10-24) `engine.ts` is 23,903 lines. 8,253 of them (34.5%) come from the last 30 days (`git blame --since=2026-09-24T08:00Z`, with history deepened to 2026-09-15). 124 commits touched it in 30 days, and 33 in 7. The file has the module preamble (lines 1–5014, 2,225 lines from the last 30 days), `class ObjectQL` (5015–23228, 317 members, 255 methods: 147 private, 108 public), and a trailer (23229–23903, 107). Sections are contiguous member ranges, docblocks included. The columns: "inferred public" lists public methods with an inferred return type (these stay), "U" counts `this.FIELD.delete/insert/update(` sites, and "private statics" lists static members that are not public (these stay). | range | section | first … last member | lines | 30-day lines | methods | inferred public | U | private statics | |---|---|---|---|---|---|---|---|---| | 5016-5240 | Instance state and constructor | `txStore` … `Constructor` | 225 | 50 | 1 (1 public) | - | 0 | - | | 5241-5263 | Status and registry accessor | `getStatus` … `registry` | 23 | 0 | 2 (2 public) | getStatus | 0 | - | | 5264-5607 | Hook and function registry | `registerHook` … `triggerHooks` | 344 | 6 | 16 (16 public) | registerHook, triggerHooks | 1 | - | | 5608-6084 | Hook dispatch internals | `hasHooksFor` … `assertBulkPerRowHookBudget` | 477 | 53 | 7 (0 public) | - | 0 | - | | 6085-6245 | Action registry and activation | `registerAction` … `setActionActive` | 161 | 19 | 10 (10 public) | - | 1 | - | | 6246-6527 | Registered resolvers and write-gate probes | `_heldFileResolver` … `registerInsertRecomputeListener` | 282 | 256 | 6 (6 public) | - | 0 | - | | 6528-7280 | Readable-ids security and permission/formula resolution | `readableIdsSecurityReported` … `resolveDefaultPermissions` | 753 | 753 | 15 (2 public) | - | 0 | - | | 7281-7388 | Authz invalidation and middleware registration | `writeEpoch` … `hasObjectMiddleware` | 108 | 19 | 4 (4 public) | - | 0 | - | | 7389-7624 | Execution plumbing (middleware, session, provenance, users) | `executeWithMiddleware` … `buildUser` | 236 | 38 | 7 (0 public) | - | 0 | - | | 7625-7955 | Driver options and engine posture | `buildDriverOptions` … `resolveEnginePosture` | 331 | 209 | 6 (0 public) | - | 0 | - | | 7956-8165 | Organization probe and system-insert organization | `probeInstallOrganizations` … `transactionCoversDriverFor` | 210 | 45 | 4 (0 public) | - | 0 | - | | 8166-8531 | Field defaults | `resolveNowDefault` … `defaultFieldEntries` | 366 | 36 | 6 (0 public) | - | 0 | - | | 8532-9142 | Autonumber | `applyAutonumbers` … `seedAutonumber` | 611 | 79 | 6 (0 public) | - | 1 | - | | 9143-9807 | Package, app, plugin and driver registration | `registrationConversionWarned` … `registerDriver` | 665 | 170 | 6 (3 public) | registerApp, registerDriver | 0 | - | | 9808-10016 | Datasource registry | `supplyFileColumnsMovedResolver` … `listUnavailableDatasources` | 209 | 1 | 8 (7 public) | - | 3 | - | | 10017-10263 | Write guard and realtime publish | `assertWriteAllowed` … `publishBulkDataEvent` | 247 | 18 | 4 (1 public) | - | 0 | - | | 10264-10385 | Validation-message i18n | `setI18nService` … `objectDisplayLabel` | 122 | 0 | 4 (1 public) | - | 0 | - | | 10386-11478 | Referential integrity and master-detail resolution | `assertReferencesResolve` … `masterDeclaredFields` | 1093 | 625 | 12 (0 public) | - | 0 | - | | 11479-11594 | Dangling-reference and cascade inspection | `inspectDanglingReferences` … `inspectCascadeDelete` | 116 | 47 | 2 (2 public) | - | 0 | - | | 11595-11689 | Crypto and tenancy providers | `setCryptoProvider` … `setTenancyPostureProvider` | 95 | 21 | 4 (4 public) | - | 1 | - | | 11690-12255 | Secret and internal-field handling | `encryptSecretFields` … `resolveInternalField` | 566 | 32 | 9 (3 public) | - | 0 | - | | 12256-12324 | Schema lookup and filter judging | `getSchema` … `judgeFilter` | 69 | 47 | 3 (2 public) | - | 0 | - | | 12325-12667 | Datasource routing | `LIFECYCLE_DATASOURCE` … `setDatasourceMapping` | 343 | 1 | 8 (3 public) | setDatasourceMapping | 0 | - | | 12668-12890 | Lifecycle init and health | `init` … `resolvePrimaryDatasource` | 223 | 0 | 3 (3 public) | init | 0 | - | | 12891-12964 | Media and value-shape presence | `objectHasMediaField` … `valueShapeStrictFor` | 74 | 13 | 4 (0 public) | - | 0 | mediaFieldPresence, coveredValueFieldPresence | | 12965-13647 | Migration flags and attestations | `isFileReferencesMigrationVerified` … `wasDatastoreCreatedFromEmpty` | 683 | 81 | 17 (8 public) | - | 0 | - | | 13648-13670 | destroy | `destroy` … `destroy` | 23 | 0 | 1 (1 public) | destroy | 0 | - | | 13671-13690 | Expand/cascade limits (statics) | `MAX_EXPAND_DEPTH` … `MAX_BULK_PER_ROW_HOOK_ROWS` | 20 | 0 | 0 (0 public) | - | 0 | MAX_EXPAND_DEPTH, MAX_CASCADE_DEPTH | | 13691-14140 | Roll-up summaries | `autonumberCounters` … `recomputeSummaries` | 450 | 0 | 7 (1 public) | - | 0 | - | | 14141-14394 | Related-record expansion | `expandRelatedRecords` … `expandRelatedRecords` | 254 | 10 | 1 (0 public) | - | 0 | - | | 14395-14838 | File-reference hydration | `resolveFileReferences` … `readParentDerivedFiles` | 444 | 310 | 5 (0 public) | - | 0 | - | | 14839-15260 | Query lowering | `resolveWhereTokens` … `requireFindOnePredicate` | 422 | 212 | 8 (0 public) | - | 0 | - | | 15261-15817 | find / findOne | `find` … `staticReadonlyCreateStrip` | 557 | 151 | 4 (2 public) | - | 0 | - | | 15818-16475 | validate | `validate` … `validate` | 658 | 493 | 1 (1 public) | - | 0 | - | | 16476-17820 | insert / insertMany | `readUpdatePriorRow` … `insertMany` | 1345 | 480 | 3 (2 public) | - | 1 | - | | 17821-19752 | update | `update` … `update` | 1932 | 391 | 1 (1 public) | - | 0 | - | | 19753-21293 | Delete cascade and reference walking | `planCascadeAtomicity` … `organizationOwnedRowCounts` | 1541 | 746 | 22 (1 public) | - | 0 | storedReferenceIncludes, remainderAfterMemberRemoval, referenceCheckContext, outlivesOrganization, keepsOrganizationReference, organizationCountContext | | 21294-21863 | delete | `delete` … `delete` | 570 | 48 | 1 (1 public) | - | 0 | - | | 21864-22476 | count / aggregate | `count` … `aggregate` | 613 | 362 | 3 (2 public) | - | 0 | - | | 22477-22864 | execute and transactions | `execute` … `enforceTransactionOrigin` | 388 | 63 | 8 (2 public) | - | 0 | - | | 22865-23228 | Compatibility API | `registerObject` … `create` | 364 | 36 | 16 (16 public) | - | 0 | - | Cut 1 is the **Validation-message i18n** row, less `setI18nService`. ## For the next dispatch (cut 2) - **Proposed area: Datasource routing**, less `setDatasourceMapping` (inferred return, stays) and the two public statics. That is `isSystemLedgerObject`, `getDriver`, `resolveDatasourceBinding`, `resolveEffectiveDatasource`, `resolveMappedDatasource`, `resolveDatasourceFromMapping` and `matchPattern`. Its 343 lines have 1 line from the last 30 days, no `this.FIELD.delete/insert/update(` site and no private static. The anchor entry's paragraph names "the same tuple ADR-0057 §3.6 routing reads", so the new module would get its own anchor entry carrying it. Measure `getDriver`'s spies and doubles before choosing it. - **The alternative, Roll-up summaries** (0 lines from the last 30 days), needs the `packages/spec/liveness/field.json` evidence pointer moved in the same slice, and `recomputeSummaries` stays. - **A limit for every later cut.** This package's `.d.ts` prints literals in single quotes, so the ruling's verbatim normaliser cannot absorb a union-member-order swap here. A cut that moves a body checked before the rest of `engine.ts` and reorders a union would show a raw diff that the normaliser leaves in place. Under the ruling's item 1 that is a stop: the normaliser must not be widened. The decision is raised in the os-dev report on the card. ## Verification - `pnpm --filter @objectstack/objectql typecheck` (`tsc --noEmit` and `check:test-typecheck`): exit 0. `eslint --no-inline-config packages/objectql`: exit 0. - `packages/objectql` suite at head `328b5b194e` (`vitest run --project local`): 466 files, 8,700 tests, 8,700 passed, 0 failed, 0 skipped. The suites that pin these helpers (`engine-validation-locale.test.ts`, `engine-validation-locale-negotiation.test.ts`, `engine-delete-restricted-locale.test.ts`) are among them. - Downstream type-checks are untouched by construction: every member moved is private, so no dependant can reach one, and all six published declaration files are byte-identical. CI's `TypeScript Type Check` covers the dependants. - `dispatch-gates --commands` derived 56 families on the real diff. Each was run at head, and its exit code was recorded and reconciled with `--ran` (56/56 accounted for). 53 exit 0. Three are NOT MEASURED locally, and CI runs them: - `check-engine-split-ratio --days 90` exit 2, CANNOT COMPUTE: the shallow clone's history starts 2026-09-15, inside the window. - `check-plugin-teardown-shape --self-test` exit 3: the pinned fixture commit is unreachable from this shallow clone. - `check:dual-build-cjs-loads` exit 3, PREREQUISITE NOT MET: it needs every package's `dist/`. The export lists above are its question for this package. - Hand gates at head, all exit 0: `check-adr-anchors`, `check-adr-symbol-anchors`, `check-nul-bytes`, `check-platform-checklist --changed-from origin/main`. --- _Generated by [Claude Code](https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2af790f commit 481ac62

2 files changed

Lines changed: 135 additions & 39 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25522 cut 1] The validation-message localisation helpers of `ObjectQL` —
5+
* `validationMessageContext` (the locale and translate hooks a rejected write
6+
* is reported through, #3957), `negotiatedMessageLocale` (the locale the
7+
* bridged i18n service can actually answer in, #15757) and
8+
* `objectDisplayLabel` (an object's display name in the caller's locale,
9+
* #7307) — moved out of `engine.ts` whole.
10+
*
11+
* A MOVE, not a redesign. Each function below is the body of the `ObjectQL`
12+
* method of the same name as it stood, re-indented one level and otherwise
13+
* unedited: the same negotiation through `resolveBundleLocale`, the same
14+
* translation-bundle → declared label → API name ladder, the same two
15+
* `catch` blocks that keep a misbehaving i18n service from turning a write
16+
* into a 500.
17+
*
18+
* ## How the moved bodies reach the class
19+
*
20+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
21+
* header carries the full reasoning), applied per method. The class keeps
22+
* every method — the published `.d.ts` lists each one's name, docblock and
23+
* position — and each body is one literal of exactly the
24+
* {@link ValidationMessageHost} members that method reads, plus a `.call`;
25+
* `objectDisplayLabel` reads no member, takes no host and is called directly.
26+
* A method is handed over as a closure that calls it on the engine at call
27+
* time, so a test double or spy set on the instance is still the one the body
28+
* reaches: `validationMessageContext` reaches `negotiatedMessageLocale`
29+
* through the engine, exactly as before.
30+
*
31+
* The one field the bodies read, `i18nService`, goes as a GETTER, so each read
32+
* happens when the body makes it — including the read inside the `translate`
33+
* closure, which runs after `validationMessageContext` has returned and sees
34+
* whatever service `setI18nService` has installed by then, as it did.
35+
* `setI18nService` itself stays in the class: it is the field's setter.
36+
*
37+
* This module imports the engine only as a TYPE, so there is no module cycle
38+
* at run time, and nothing re-exports it, so the published surface does not
39+
* move.
40+
*/
41+
42+
import type { ObjectQL } from './engine.js';
43+
import { objectLabelKey, resolveBundleLocale } from '@objectstack/spec/system';
44+
45+
/**
46+
* The `ObjectQL` members the functions below read — every one of them, and no
47+
* other; each function's `this` is the `Pick` of the members IT reads. Each is
48+
* typed AS the class member (an indexed access on the class), so a change to a
49+
* member's signature on the class is a change here too, and naming a member
50+
* the class does not have is a compile error. See the module header for which
51+
* member is a getter.
52+
*/
53+
export interface ValidationMessageHost {
54+
readonly i18nService: ObjectQL['i18nService'];
55+
negotiatedMessageLocale: ObjectQL['negotiatedMessageLocale'];
56+
}
57+
58+
/** The body of `ObjectQL#validationMessageContext`; the method's docblock there is its contract. */
59+
export function validationMessageContext(
60+
this: Pick<ValidationMessageHost, 'i18nService' | 'negotiatedMessageLocale'>,
61+
objectName: string,
62+
// Only `locale` is read — narrower than `ExecutionContext` so both the
63+
// resolved and the input-shaped envelope satisfy it.
64+
context?: { locale?: string },
65+
): { locale?: string; translate?: (key: string, locale: string, params?: Record<string, unknown>) => string; objectName: string } {
66+
const t = this.i18nService?.t;
67+
return {
68+
objectName,
69+
locale: this.negotiatedMessageLocale(context?.locale),
70+
translate: t ? (key, locale, params) => t.call(this.i18nService, key, locale, params) : undefined,
71+
};
72+
}
73+
74+
/** The body of `ObjectQL#negotiatedMessageLocale`; the method's docblock there is its contract. */
75+
export function negotiatedMessageLocale(
76+
this: Pick<ValidationMessageHost, 'i18nService'>,
77+
requested: string | undefined): string | undefined {
78+
if (!requested) return requested;
79+
const getLocales = this.i18nService?.getLocales;
80+
if (typeof getLocales !== 'function') return requested;
81+
let available: unknown;
82+
try {
83+
available = getLocales.call(this.i18nService);
84+
} catch {
85+
// A misbehaving i18n service must not turn a write into a 500.
86+
return requested;
87+
}
88+
if (!Array.isArray(available) || available.length === 0) return requested;
89+
// `resolveBundleLocale` matches against a bundle's KEYS; the offered
90+
// locales are exactly that key set, with no data behind them.
91+
const offered: Record<string, true> = {};
92+
for (const code of available) {
93+
if (typeof code === 'string' && code.length > 0) offered[code] = true;
94+
}
95+
return resolveBundleLocale(offered, requested) ?? requested;
96+
}
97+
98+
/** The body of `ObjectQL#objectDisplayLabel`; the method's docblock there is its contract. */
99+
export function objectDisplayLabel(
100+
objectName: string,
101+
declaredLabel: unknown,
102+
ctx: { locale?: string; translate?: (key: string, locale: string, params?: Record<string, unknown>) => string },
103+
): string {
104+
if (ctx.translate && ctx.locale) {
105+
const key = objectLabelKey(objectName);
106+
try {
107+
const translated = ctx.translate(key, ctx.locale);
108+
// II18nService echoes the key back on a miss.
109+
if (typeof translated === 'string' && translated.length > 0 && translated !== key) {
110+
return translated;
111+
}
112+
} catch {
113+
// A misbehaving i18n service must not turn a 409 into a 500.
114+
}
115+
}
116+
const declared = typeof declaredLabel === 'string' ? declaredLabel.trim() : '';
117+
return declared.length > 0 ? declared : objectName;
118+
}

‎packages/objectql/src/engine.ts‎

Lines changed: 17 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,11 @@ import {
9797
isDataMigrationFlagVerified,
9898
hasMovedFileColumns,
9999
renderOperationMessage,
100-
objectLabelKey,
101-
resolveBundleLocale,
102100
SystemFieldName,
103101
} from '@objectstack/spec/system';
102+
// [#25522 cut 1] The validation-message localisation helpers; the methods of the
103+
// same names below hand each body the members it reads (`ValidationMessageHost`).
104+
import { negotiatedMessageLocale, objectDisplayLabel, validationMessageContext } from './engine-validation-messages.js';
104105
import { ExecutionContext, ExecutionContextSchema, type InstalledPackageSource } from '@objectstack/spec/kernel';
105106
// [#21120] The family-wide stored-metadata-body seam. A `data.record.*` event
106107
// carries the written row in `after` (and the input patch in `changes`), so a
@@ -10294,12 +10295,13 @@ export class ObjectQL implements IObjectQLEngine {
1029410295
// resolved and the input-shaped envelope satisfy it.
1029510296
context?: { locale?: string },
1029610297
): { locale?: string; translate?: (key: string, locale: string, params?: Record<string, unknown>) => string; objectName: string } {
10297-
const t = this.i18nService?.t;
10298-
return {
10299-
objectName,
10300-
locale: this.negotiatedMessageLocale(context?.locale),
10301-
translate: t ? (key, locale, params) => t.call(this.i18nService, key, locale, params) : undefined,
10298+
// [#25522 cut 1] The body moved to `engine-validation-messages.ts` (see `ValidationMessageHost`).
10299+
const readI18nService = () => this.i18nService;
10300+
const host: ThisParameterType<typeof validationMessageContext> = {
10301+
get i18nService() { return readI18nService(); },
10302+
negotiatedMessageLocale: (...a) => this.negotiatedMessageLocale(...a),
1030210303
};
10304+
return validationMessageContext.call(host, objectName, context);
1030310305
}
1030410306

1030510307
/**
@@ -10333,24 +10335,12 @@ export class ObjectQL implements IObjectQLEngine {
1033310335
* EXISTS; inventing one is how a second rule gets born.
1033410336
*/
1033510337
private negotiatedMessageLocale(requested: string | undefined): string | undefined {
10336-
if (!requested) return requested;
10337-
const getLocales = this.i18nService?.getLocales;
10338-
if (typeof getLocales !== 'function') return requested;
10339-
let available: unknown;
10340-
try {
10341-
available = getLocales.call(this.i18nService);
10342-
} catch {
10343-
// A misbehaving i18n service must not turn a write into a 500.
10344-
return requested;
10345-
}
10346-
if (!Array.isArray(available) || available.length === 0) return requested;
10347-
// `resolveBundleLocale` matches against a bundle's KEYS; the offered
10348-
// locales are exactly that key set, with no data behind them.
10349-
const offered: Record<string, true> = {};
10350-
for (const code of available) {
10351-
if (typeof code === 'string' && code.length > 0) offered[code] = true;
10352-
}
10353-
return resolveBundleLocale(offered, requested) ?? requested;
10338+
// [#25522 cut 1] The body moved to `engine-validation-messages.ts` (see `ValidationMessageHost`).
10339+
const readI18nService = () => this.i18nService;
10340+
const host: ThisParameterType<typeof negotiatedMessageLocale> = {
10341+
get i18nService() { return readI18nService(); },
10342+
};
10343+
return negotiatedMessageLocale.call(host, requested);
1035410344
}
1035510345

1035610346
/**
@@ -10368,20 +10358,8 @@ export class ObjectQL implements IObjectQLEngine {
1036810358
declaredLabel: unknown,
1036910359
ctx: { locale?: string; translate?: (key: string, locale: string, params?: Record<string, unknown>) => string },
1037010360
): string {
10371-
if (ctx.translate && ctx.locale) {
10372-
const key = objectLabelKey(objectName);
10373-
try {
10374-
const translated = ctx.translate(key, ctx.locale);
10375-
// II18nService echoes the key back on a miss.
10376-
if (typeof translated === 'string' && translated.length > 0 && translated !== key) {
10377-
return translated;
10378-
}
10379-
} catch {
10380-
// A misbehaving i18n service must not turn a 409 into a 500.
10381-
}
10382-
}
10383-
const declared = typeof declaredLabel === 'string' ? declaredLabel.trim() : '';
10384-
return declared.length > 0 ? declared : objectName;
10361+
// [#25522 cut 1] The body moved to `engine-validation-messages.ts`; it reads no member.
10362+
return objectDisplayLabel(objectName, declaredLabel, ctx);
1038510363
}
1038610364

1038710365
/**

0 commit comments

Comments
 (0)