diff --git a/.changeset/delete-restricted-user-copy.md b/.changeset/delete-restricted-user-copy.md new file mode 100644 index 0000000000..b9e1734a84 --- /dev/null +++ b/.changeset/delete-restricted-user-copy.md @@ -0,0 +1,72 @@ +--- +"@objectstack/spec": minor +"@objectstack/objectql": minor +"@objectstack/rest": minor +--- + +fix(objectql,rest,spec): the `DELETE_RESTRICTED` 409 stops handing a business user a developer instruction + +Deleting a record that other records reference is correctly refused with +`409 DELETE_RESTRICTED`. The transport was never the problem — `status` is set +and the structured fields survive the mapper. What reached the end user was: +`error.message` is shipped verbatim as `body.error` by `mapDataError`, and +Console renders that as-is in a toast. So an operator deleting a 部门 in a fully +Chinese app read + +``` +Cannot delete sys_business_unit (): 1 dependent os_tianshun_ehr_sporadic_application +record(s) reference it via apply_dept (apply_dept is required, so it cannot be +cleared). Delete or reassign them first, or set deleteBehavior:'cascade' on +os_tianshun_ehr_sporadic_application.apply_dept. +``` + +— an English sentence in a zh-CN UI, naming two tables and a column they have +never seen (they know them as 「零星申请」 and 「申报部门」), ending in a +metadata-authoring instruction a business user cannot act on and will open a +support ticket about. + +**The error now carries two messages, because it has two audiences.** + +- `message` is the **user's** half: rendered in the caller's locale + (`ExecutionContext.locale`) from a new built-in catalog, against resolved + **labels** for the object, the dependent object and the referencing field — + translation bundle → declared `label` → API name, so the API name is where the + ladder ends rather than where it starts. The actionable half of the old advice + ("delete or reassign them first") stays; `deleteBehavior` does not appear in + any locale. +- `developerMessage` is the **developer's** half, and is the previous sentence + byte for byte: English, API names, and the `deleteBehavior:'cascade'` remedy. + The guidance is correct and useful — it is moved to a channel that reaches + developers, not deleted. `@objectstack/rest` ships it as a sibling field of the + 409 body (it discloses nothing the envelope did not already carry: `object` and + `dependentObject` are API names on the same body), and the engine's delete + error log now carries it too, so a zh-CN deployment's server log does not lose + its operator detail to the localized sentence. + +`code`, `status`, `object`, `dependentObject` and `dependentCount` are +unchanged, and the wire code does **not** split — one `DELETE_RESTRICTED` +(ADR-0112), two sentences, exactly as the field catalog splits a message key +without splitting `FieldErrorCode`. + +**New in `@objectstack/spec/system`** (`operation-message.ts`): the operation +message catalog — `renderOperationMessage`, `BUILTIN_OPERATION_MESSAGES` +(`en` / `zh-CN` / `ja-JP` / `es-ES`), `operationMessageTranslationKey`, plus +`objectLabelKey` in `i18n-resolver`. A deployment overrides any sentence with a +`translation` item under `errors.`. It is a **separate** catalog from +`validation-message.ts` deliberately: that one is addressed `validation.field.*` +because every entry names a field and the constraint it broke, and a +`DELETE_RESTRICTED` names neither — the offending field is on a different object +from the one the caller acted on, and there is no `fields[]` entry to hang it +off. Filing it there would give deployments an override key that lies about what +it overrides. + +`minor`, not `major`: nothing breaks. The structured fields clients match on are +untouched, no test or doc ever pinned the message text, and both new fields are +additive. `check-changeset-no-major.mjs` is the second reason — every publishable +package is in the Changesets `fixed` group, so one `major` promotes all ~70 +packages, and the launch-window convention ships even genuinely breaking changes +as `minor`. + +This is #3957's fix reached from the operation side: same defect (platform copy +composed in English with API names concatenated in), same machinery, one layer +up. diff --git a/content/docs/protocol/objectql/types.mdx b/content/docs/protocol/objectql/types.mdx index 55a8002732..0c322a0cfa 100644 --- a/content/docs/protocol/objectql/types.mdx +++ b/content/docs/protocol/objectql/types.mdx @@ -625,6 +625,15 @@ const opportunities = await engine.find('opportunity', { > "<field> is required" validation error. To delete the children along with > the parent, set `deleteBehavior: cascade` explicitly. An explicit `set_null` > or `cascade` is always honored as written. +> +> The refusal carries **two** messages, for two audiences. `error` is written for +> the person who clicked delete: it is rendered in the caller's locale from the +> built-in catalog and names the objects and the field by their **labels**, so a +> client may show it to an end user as-is. `developerMessage` is the operator's +> copy — English, API names, and the `deleteBehavior: cascade` remedy — and +> should not be surfaced to end users. Override any locale's sentence with a +> `translation` item under `errors.delete_restricted` / +> `errors.delete_restricted_required`. **Multiple lookups:** ```yaml diff --git a/packages/objectql/src/engine-cascade-delete.test.ts b/packages/objectql/src/engine-cascade-delete.test.ts index abfc07a25a..3608f22809 100644 --- a/packages/objectql/src/engine-cascade-delete.test.ts +++ b/packages/objectql/src/engine-cascade-delete.test.ts @@ -109,6 +109,15 @@ describe('cascadeDeleteRelations — required FK escalates set_null → restrict await expect(engine.delete('acct', { where: { id: a.id } } as any)) .rejects.toMatchObject({ code: 'DELETE_RESTRICTED', status: 409, dependentObject: 'opp', dependentCount: 1 }); + // [#7307] The refusal's copy is now SPLIT in two. The structured fields + // above are unchanged — this pins which half says what, so a later edit + // cannot quietly put the API names back in front of an end user. + const err = await engine.delete('acct', { where: { id: a.id } } as any).catch((e) => e); + expect(err.message).toContain('Opportunity'); // the label, … + expect(err.message).not.toContain('opp'); // … not the API name, + expect(err.message).not.toMatch(/deleteBehavior/); // … and no authoring hint. + expect(err.developerMessage).toContain("set deleteBehavior:'cascade' on opp.account"); + // Nothing was deleted or mutated. expect(await engine.findOne('acct', { where: { id: a.id } })).toBeTruthy(); expect((await engine.find('opp', {})).length).toBe(1); diff --git a/packages/objectql/src/engine-delete-restricted-locale.test.ts b/packages/objectql/src/engine-delete-restricted-locale.test.ts new file mode 100644 index 0000000000..b0205c57be --- /dev/null +++ b/packages/objectql/src/engine-delete-restricted-locale.test.ts @@ -0,0 +1,253 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #7307 — the CALL SITE of the `DELETE_RESTRICTED` copy, with a REAL + * {@link ObjectQL} engine + stub driver. + * + * The refusal itself was never in doubt: `cascadeDeleteRelations` correctly + * declines the delete with `409 DELETE_RESTRICTED` and the transport ships it + * intact. What reached the end user was the problem. REST puts `error.message` + * verbatim into the flat 409 envelope (`mapDataError`) and Console renders that + * as-is in a toast, so an operator deleting a 部门 in a fully Chinese app read + * an English sentence naming two tables and a column — ending in + * `set deleteBehavior:'cascade' on …`, a metadata-authoring instruction they + * cannot act on. + * + * These tests assert the SPLIT: `message` is the user's half (their locale, + * labels, no developer vocabulary) and `developerMessage` is the developer's + * half (English, API names, the remedy) — and the structured fields the wire + * contract is built on are byte-identical to before. + * + * The catalog half is pinned in + * `packages/spec/src/system/operation-message.test.ts`. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from './engine.js'; + +/** The reporter's shape: a business unit referenced by a REQUIRED lookup. */ +const businessUnit = { + name: 'sys_business_unit', + label: 'Business Unit', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', type: 'text' as const }, + }, +}; +const sporadicApplication = { + name: 'os_ehr_sporadic_application', + label: 'Sporadic Application', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + title: { name: 'title', type: 'text' as const }, + apply_dept: { + name: 'apply_dept', type: 'lookup' as const, reference: 'sys_business_unit', + label: 'Applying Department', required: true, + }, + }, +}; +/** An EXPLICIT restrict on a NULLABLE FK — the other sentence variant. */ +const archiveNote = { + name: 'os_ehr_archive_note', + label: 'Archive Note', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + body: { name: 'body', type: 'text' as const }, + dept: { + name: 'dept', type: 'lookup' as const, reference: 'sys_business_unit', + label: 'Department', deleteBehavior: 'restrict', + }, + }, +}; + +/** Every API name that must never appear in a message a business user reads. */ +const API_NAMES = ['sys_business_unit', 'os_ehr_sporadic_application', 'apply_dept']; + +function makeStubDriver() { + const stores = new Map>>(); + const storeFor = (o: string) => { let s = stores.get(o); if (!s) { s = new Map(); stores.set(o, s); } return s; }; + let nextId = 0; + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) continue; + const exp = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + if ((row[k] ?? null) !== (exp ?? null)) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(o: string, ast: any) { return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); }, + async findOne(o: string, ast: any) { for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; return null; }, + async create(o: string, data: Record) { + nextId += 1; const id = (data.id as string) ?? `r_${nextId}`; const row = { ...data, id }; storeFor(o).set(id, row); return row; + }, + async update(o: string, id: string, data: Record) { + const s = storeFor(o); const cur = s.get(id); if (!cur) throw new Error(`nf ${o}/${id}`); + const up = { ...cur, ...data, id }; s.set(id, up); return up; + }, + async upsert(o: string, data: Record) { const id = data.id as string | undefined; return id && storeFor(o).has(id) ? this.update(o, id, data) : this.create(o, data); }, + async delete(o: string, id: string) { return storeFor(o).delete(id); }, + async count(o: string, ast: any) { return (await this.find(o, ast)).length; }, + async bulkCreate(o: string, rows: Record[]) { return Promise.all(rows.map((r) => this.create(o, r))); }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, async commit() {}, async rollback() {}, + }; + return { driver }; +} + +/** The zh-CN bundle the reporter's deployment ships, as an `II18nService`. */ +const ZH_BUNDLE: Record = { + 'objects.sys_business_unit.label': '部门', + 'objects.os_ehr_sporadic_application.label': '零星申请', + 'objects.os_ehr_sporadic_application.fields.apply_dept.label': '申报部门', +}; +// Locale-aware, like a real `II18nService`: a bundle it does not carry echoes +// the key back, which is the contract every resolver here detects a miss by. +const zhI18n = { t: (key: string, locale: string) => (locale?.startsWith('zh') ? ZH_BUNDLE[key] ?? key : key) }; + +async function makeEngine(i18n?: { t: (k: string, l: string) => string }) { + const engine = new ObjectQL(); + const { driver } = makeStubDriver(); + engine.registerDriver(driver, true); + await engine.init(); + for (const o of [businessUnit, sporadicApplication, archiveNote]) engine.registry.registerObject(o as any); + if (i18n) engine.setI18nService(i18n); + return engine; +} + +/** Seed one parent + one required-FK child and return the refusal it throws. */ +async function refuseDelete(engine: ObjectQL, locale?: string): Promise { + const bu = await engine.insert('sys_business_unit', { name: 'HR' }); + await engine.insert('os_ehr_sporadic_application', { title: '差旅', apply_dept: bu.id }); + try { + await engine.delete('sys_business_unit', { where: { id: bu.id }, context: { locale } } as any); + } catch (e) { + return e; + } + throw new Error('expected the delete to be refused'); +} + +describe('#7307 DELETE_RESTRICTED — user copy vs developer guidance', () => { + let engine: ObjectQL; + + describe('with no i18n service (the bare-kernel / programmatic caller)', () => { + beforeEach(async () => { engine = await makeEngine(); }); + + it('still refuses the delete: 409, DELETE_RESTRICTED, structured fields unchanged', async () => { + const err = await refuseDelete(engine); + expect(err).toMatchObject({ + code: 'DELETE_RESTRICTED', + status: 409, + object: 'sys_business_unit', + dependentObject: 'os_ehr_sporadic_application', + dependentCount: 1, + }); + }); + + it('names the objects by their DECLARED labels, never by API name', async () => { + const err = await refuseDelete(engine); + expect(err.message).toContain('Business Unit'); + expect(err.message).toContain('Sporadic Application'); + expect(err.message).toContain('Applying Department'); + for (const api of API_NAMES) expect(err.message).not.toContain(api); + }); + + it('does not hand the user a metadata-authoring instruction', async () => { + const err = await refuseDelete(engine); + expect(err.message).not.toMatch(/deleteBehavior|cascade/i); + // The half that IS actionable for a user survives. + expect(err.message).toMatch(/Delete or reassign them first/); + }); + }); + + describe('with a zh-CN deployment (the reported app)', () => { + beforeEach(async () => { engine = await makeEngine(zhI18n); }); + + it('renders the toast sentence in the caller locale, with TRANSLATED labels', async () => { + const err = await refuseDelete(engine, 'zh-CN'); + expect(err.message).toBe( + '该部门正被 1 条零星申请记录通过「申报部门」引用,且该字段为必填、无法清空,请先删除或改派这些记录。', + ); + }); + + it('leaks no API name and no developer vocabulary into the toast', async () => { + const err = await refuseDelete(engine, 'zh-CN'); + for (const api of API_NAMES) expect(err.message).not.toContain(api); + expect(err.message).not.toMatch(/deleteBehavior|cascade/i); + }); + + it('an i18n service that THROWS still yields a 409, not a 500, and still no leak', async () => { + const boom = await makeEngine({ t: () => { throw new Error('i18n down'); } }); + const err = await refuseDelete(boom, 'zh-CN'); + expect(err).toMatchObject({ code: 'DELETE_RESTRICTED', status: 409 }); + expect(err.message).not.toMatch(/deleteBehavior/i); + }); + + it('an unresolved locale falls back to English rather than to the API names', async () => { + const err = await refuseDelete(engine, 'fr-FR'); + expect(err.message).toContain('Business Unit'); + for (const api of API_NAMES) expect(err.message).not.toContain(api); + }); + }); + + describe('developerMessage — the guidance is moved, not lost', () => { + beforeEach(async () => { engine = await makeEngine(zhI18n); }); + + it('carries the API names and the deleteBehavior remedy, in English, even for a zh-CN caller', async () => { + const err = await refuseDelete(engine, 'zh-CN'); + expect(err.developerMessage).toContain('sys_business_unit'); + expect(err.developerMessage).toContain('os_ehr_sporadic_application'); + expect(err.developerMessage).toContain('apply_dept'); + expect(err.developerMessage).toContain( + "set deleteBehavior:'cascade' on os_ehr_sporadic_application.apply_dept", + ); + }); + + it('is the pre-#7307 sentence verbatim, so nothing a developer relied on changed wording', async () => { + const err = await refuseDelete(engine, 'zh-CN'); + expect(err.developerMessage).toMatch( + /^Cannot delete sys_business_unit \(.+\): 1 dependent os_ehr_sporadic_application record\(s\) reference it via apply_dept \(apply_dept is required, so it cannot be cleared\)\. Delete or reassign them first, or set deleteBehavior:'cascade' on os_ehr_sporadic_application\.apply_dept\.$/, + ); + }); + + it('is a SEPARATE field — the user-facing message never contains it', async () => { + const err = await refuseDelete(engine, 'zh-CN'); + expect(err.message).not.toContain(err.developerMessage); + expect(err.message).not.toBe(err.developerMessage); + }); + + it('reaches the SERVER LOG, so a zh-CN deployment does not log its operator half in Chinese', async () => { + const logged: Array> = []; + const original = (engine as any).logger.error.bind((engine as any).logger); + (engine as any).logger.error = (msg: string, err: unknown, meta: Record) => { + logged.push(meta ?? {}); + return original(msg, err, meta); + }; + await refuseDelete(engine, 'zh-CN'); + expect(logged.some((m) => typeof m.developerMessage === 'string' + && (m.developerMessage as string).includes("deleteBehavior:'cascade'"))).toBe(true); + }); + }); + + describe('an EXPLICIT restrict on a nullable FK gets the other sentence', () => { + beforeEach(async () => { engine = await makeEngine(zhI18n); }); + + it('omits the "required, cannot be cleared" clause it has no right to claim', async () => { + const bu = await engine.insert('sys_business_unit', { name: 'Finance' }); + await engine.insert('os_ehr_archive_note', { body: 'n', dept: bu.id }); + const err = await engine + .delete('sys_business_unit', { where: { id: bu.id }, context: { locale: 'zh-CN' } } as any) + .then(() => { throw new Error('expected refusal'); }, (e) => e); + + expect(err).toMatchObject({ code: 'DELETE_RESTRICTED', status: 409, dependentCount: 1 }); + expect(err.message).not.toContain('必填'); + expect(err.message).toContain('请先删除或改派这些记录'); + // No label on the child object's translation entries → declared label. + expect(err.message).toContain('Archive Note'); + expect(err.developerMessage).not.toContain('is required, so it cannot be cleared'); + }); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index e891720cc9..57fe02463f 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -43,6 +43,8 @@ import { FILE_REFERENCES_MIGRATION_ID, VALUE_SHAPES_MIGRATION_ID, isDataMigrationFlagVerified, + renderOperationMessage, + objectLabelKey, } from '@objectstack/spec/system'; import { ExecutionContext, ExecutionContextSchema } from '@objectstack/spec/kernel'; import type { FlowFunctionEffect } from '@objectstack/spec/automation'; @@ -133,7 +135,7 @@ import { ExpressionEngine } from '@objectstack/formula'; import type { Expression } from '@objectstack/spec'; import { isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec'; import { bindHooksToEngine } from './hook-binder.js'; -import { validateRecord, normalizeMultiValueFields, coerceBooleanFields, ValidationError, buildFieldError, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js'; +import { validateRecord, normalizeMultiValueFields, coerceBooleanFields, ValidationError, buildFieldError, resolveFieldLabel, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js'; import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink } from './validation/record-validator.js'; import { evaluateValidationRules, needsPriorRecord, stripReadonlyWhenFields, stripReadonlyWhenFieldsMulti, hasReadonlyWhenInPayload, hasParentScopedReadonlyWhenInPayload, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields } from './validation/rule-validator.js'; import { resolveMasterDetailRelation } from './master-detail.js'; @@ -3928,6 +3930,37 @@ export class ObjectQL implements IObjectQLEngine { }; } + /** + * An OBJECT's display name in the caller's locale: translation bundle → + * declared `label` → API name (#7307). + * + * The object-level twin of `resolveFieldLabel` (`record-validator.ts`), and + * deliberately the same three-step ladder with the same last resort: the API + * name is what a user must not be shown, so it is where the ladder ENDS, not + * where it starts. It stays available to clients on the structured fields + * (`object` / `dependentObject`) and on `developerMessage`. + */ + private objectDisplayLabel( + objectName: string, + declaredLabel: unknown, + ctx: { locale?: string; translate?: (key: string, locale: string, params?: Record) => string }, + ): string { + if (ctx.translate && ctx.locale) { + const key = objectLabelKey(objectName); + try { + const translated = ctx.translate(key, ctx.locale); + // II18nService echoes the key back on a miss. + if (typeof translated === 'string' && translated.length > 0 && translated !== key) { + return translated; + } + } catch { + // A misbehaving i18n service must not turn a 409 into a 500. + } + } + const declared = typeof declaredLabel === 'string' ? declaredLabel.trim() : ''; + return declared.length > 0 ? declared : objectName; + } + /** * [#4441] Referential integrity on the WRITE path: a `lookup` (or any * reference-typed field) may not be given an id that exists in no row of the @@ -8258,13 +8291,52 @@ export class ObjectQL implements IObjectQLEngine { if (!dependents || dependents.length === 0) continue; if (behavior === 'restrict') { - const reason = fdef.deleteBehavior !== 'restrict' && fdef.required === true - ? ` (${fieldName} is required, so it cannot be cleared)` - : ''; + // [#7307] TWO messages, two audiences — because this error has two + // and they were sharing one string. + // + // `message` is what a BUSINESS USER reads: REST ships it verbatim as + // `body.error` in the flat 409 envelope (`mapDataError`), and Console + // renders that as-is in a toast. It was composed English-only with the + // API names concatenated in and a metadata-authoring instruction on + // the end, so an operator deleting a 部门 in a fully Chinese app got an + // English sentence naming two tables and a column they have never + // seen, ending in advice only a developer can act on. It is now + // rendered through the operation-message catalog in the caller's + // locale against resolved LABELS — the same fix #3957 made one layer + // down for field constraints, reached from the operation side. + // + // `developerMessage` is what the DEVELOPER reads, and is the previous + // sentence unchanged, byte for byte: English, API names, and the + // `deleteBehavior:'cascade'` hint — which is correct and useful, and + // is not lost, it is addressed. It rides the structured half of the + // envelope alongside `dependentObject` / `dependentCount`, which + // already carry API names, so it discloses nothing new; no + // user-facing surface reads it. + // + // The wire code does NOT split: one `DELETE_RESTRICTED` (ADR-0112), + // two SENTENCES, exactly as the field catalog splits a message key + // without splitting `FieldErrorCode`. + const required = fdef.deleteBehavior !== 'restrict' && fdef.required === true; + const msgCtx = this.validationMessageContext(object, context); + const parent = objects.find((o) => (o as any)?.name === object); const err: any = new Error( - `Cannot delete ${object} (${id}): ${dependents.length} dependent ${childName} record(s) reference it via ${fieldName}${reason}. ` + - `Delete or reassign them first, or set deleteBehavior:'cascade' on ${childName}.${fieldName}.`, + renderOperationMessage( + { + messageKey: required ? 'delete_restricted_required' : 'delete_restricted', + params: { + object: this.objectDisplayLabel(object, (parent as any)?.label, msgCtx), + dependentObject: this.objectDisplayLabel(childName, (child as any)?.label, msgCtx), + field: resolveFieldLabel(fieldName, fdef, { ...msgCtx, objectName: childName }), + count: dependents.length, + }, + }, + { locale: msgCtx.locale, translate: msgCtx.translate }, + ), ); + err.developerMessage = + `Cannot delete ${object} (${id}): ${dependents.length} dependent ${childName} record(s) reference it via ${fieldName}` + + `${required ? ` (${fieldName} is required, so it cannot be cleared)` : ''}. ` + + `Delete or reassign them first, or set deleteBehavior:'cascade' on ${childName}.${fieldName}.`; err.code = 'DELETE_RESTRICTED'; err.status = 409; err.object = object; @@ -8641,7 +8713,17 @@ export class ObjectQL implements IObjectQLEngine { if (summaryFailures.length > 0) throw new SummaryRecomputeError(summaryFailures, hookContext.result); return hookContext.result; } catch (e) { - this.logger.error('Delete operation failed', e as Error, { object }); + // [#7307] `Error.message` is now the END USER's localized sentence for + // a `DELETE_RESTRICTED`, and the logger serializes only `message` + + // `stack` — so without this the operator-facing half (API names, the + // `deleteBehavior:'cascade'` remedy) would reach no channel at all, + // and the server log of a zh-CN deployment would read in Chinese. An + // error that carries no `developerMessage` logs exactly as before. + const devDetail = (e as any)?.developerMessage; + this.logger.error('Delete operation failed', e as Error, { + object, + ...(typeof devDetail === 'string' && devDetail.length > 0 ? { developerMessage: devDetail } : {}), + }); throw e; } }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index de85c7eb0f..3dc131bd85 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -601,6 +601,19 @@ export function mapDataError(error: any, object?: string): { status: number; bod body: { error: error?.message ?? 'Cannot delete: dependent records exist', code: 'DELETE_RESTRICTED', + // [#7307] `error` is the END USER's half — localized, labels + // only — because Console renders it verbatim in a toast. + // `developerMessage` is the other half the engine now splits + // out: the API names and the `deleteBehavior:'cascade'` remedy, + // in a field no user-facing surface reads. Shipping it here is + // what keeps the guidance REACHABLE for the app builder who is + // hitting this over HTTP — dropping it at the transport would + // move the defect rather than fix it. It discloses nothing the + // envelope did not already carry: `dependentObject` and + // `object` are API names on the same body. + ...(typeof error?.developerMessage === 'string' && error.developerMessage.length > 0 + ? { developerMessage: error.developerMessage } + : {}), ...(error?.dependentObject ? { dependentObject: error.dependentObject } : {}), ...(typeof error?.dependentCount === 'number' ? { dependentCount: error.dependentCount } : {}), ...(object ? { object } : {}), diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 718346699a..b69632e03b 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -2419,6 +2419,40 @@ describe('mapDataError — schema/constraint envelopes', () => { expect(r.body.dependentCount).toBe(1); }); + // [#7307] Two audiences, two fields. `error` is what Console renders verbatim + // in a toast; `developerMessage` is where the API names and the + // `deleteBehavior` remedy live. The transport must carry both and must not + // merge them. + it('ships DELETE_RESTRICTED developerMessage alongside the user-facing error', () => { + const r = mapDataError( + Object.assign(new Error('该部门正被 1 条零星申请记录通过「申报部门」引用,请先删除或改派这些记录。'), { + code: 'DELETE_RESTRICTED', + status: 409, + developerMessage: + "Cannot delete sys_business_unit (b1): 1 dependent os_ehr_app record(s) reference it via apply_dept. " + + "Delete or reassign them first, or set deleteBehavior:'cascade' on os_ehr_app.apply_dept.", + dependentObject: 'os_ehr_app', + dependentCount: 1, + }), + 'sys_business_unit', + ); + expect(r.status).toBe(409); + expect(r.body.error).toBe('该部门正被 1 条零星申请记录通过「申报部门」引用,请先删除或改派这些记录。'); + expect(r.body.error).not.toMatch(/deleteBehavior/); + expect(r.body.developerMessage).toContain("set deleteBehavior:'cascade' on os_ehr_app.apply_dept"); + }); + + it('omits developerMessage entirely when the thrower carries none', () => { + const r = mapDataError( + Object.assign(new Error('Cannot delete sys_position (p1): 1 dependent record'), { + code: 'DELETE_RESTRICTED', + status: 409, + }), + 'sys_position', + ); + expect(r.body).not.toHaveProperty('developerMessage'); + }); + it('maps SQLite "has no column named" → 400 INVALID_FIELD with the field', () => { const r = mapDataError( sqliteError( diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index c625aa85c0..a032c6c710 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -44,6 +44,7 @@ "AwarenessUpdateSchema (const)", "AwarenessUserState (type)", "AwarenessUserStateSchema (const)", + "BUILTIN_OPERATION_MESSAGES (const)", "BUILTIN_VALIDATION_MESSAGES (const)", "BackupConfig (type)", "BackupConfigParsed (type)", @@ -438,6 +439,8 @@ "NavNodeLike (interface)", "NotificationChannel (type)", "NotificationChannelSchema (const)", + "OPERATION_MESSAGE_FALLBACK_LOCALE (const)", + "OPERATION_MESSAGE_KEY_PREFIX (const)", "ORSet (type)", "ORSetElement (type)", "ORSetElementParsed (type)", @@ -514,6 +517,8 @@ "RegistryUpstreamSchema (const)", "RemoveFieldOperation (type)", "RenameObjectOperation (type)", + "RenderOperationMessageInput (interface)", + "RenderOperationMessageOptions (interface)", "RenderValidationMessageInput (interface)", "RenderValidationMessageOptions (interface)", "ResolveOptions (interface)", @@ -777,7 +782,10 @@ "isPublicAudience (function)", "minioStorageExample (const)", "objectFieldLabelKey (function)", + "objectLabelKey (function)", + "operationMessageTranslationKey (function)", "preferredLocaleFromHeader (function)", + "renderOperationMessage (function)", "renderValidationMessage (function)", "resolveActionConfirm (function)", "resolveActionLabel (function)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 00dc41ac73..0564f2b104 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -44,6 +44,7 @@ "AwarenessUpdateSchema": "src/system/collaboration.zod.ts#AwarenessUpdateSchema (const)", "AwarenessUserState": "src/system/collaboration.zod.ts#AwarenessUserState (type)", "AwarenessUserStateSchema": "src/system/collaboration.zod.ts#AwarenessUserStateSchema (const)", + "BUILTIN_OPERATION_MESSAGES": "src/system/operation-message.ts#BUILTIN_OPERATION_MESSAGES (const)", "BUILTIN_VALIDATION_MESSAGES": "src/system/validation-message.ts#BUILTIN_VALIDATION_MESSAGES (const)", "BackupConfig": "src/system/disaster-recovery.zod.ts#BackupConfig (type)", "BackupConfigParsed": "src/system/disaster-recovery.zod.ts#BackupConfigParsed (type)", @@ -438,6 +439,8 @@ "NavNodeLike": "src/system/i18n-resolver.ts#NavNodeLike (interface)", "NotificationChannel": "src/system/notification.zod.ts#NotificationChannel (type)", "NotificationChannelSchema": "src/system/notification.zod.ts#NotificationChannelSchema (const)", + "OPERATION_MESSAGE_FALLBACK_LOCALE": "src/system/operation-message.ts#OPERATION_MESSAGE_FALLBACK_LOCALE (const)", + "OPERATION_MESSAGE_KEY_PREFIX": "src/system/operation-message.ts#OPERATION_MESSAGE_KEY_PREFIX (const)", "ORSet": "src/system/collaboration.zod.ts#ORSet (type)", "ORSetElement": "src/system/collaboration.zod.ts#ORSetElement (type)", "ORSetElementParsed": "src/system/collaboration.zod.ts#ORSetElementParsed (type)", @@ -514,6 +517,8 @@ "RegistryUpstreamSchema": "src/system/registry-config.zod.ts#RegistryUpstreamSchema (const)", "RemoveFieldOperation": "src/system/migration.zod.ts#RemoveFieldOperation (type)", "RenameObjectOperation": "src/system/migration.zod.ts#RenameObjectOperation (type)", + "RenderOperationMessageInput": "src/system/operation-message.ts#RenderOperationMessageInput (interface)", + "RenderOperationMessageOptions": "src/system/operation-message.ts#RenderOperationMessageOptions (interface)", "RenderValidationMessageInput": "src/system/validation-message.ts#RenderValidationMessageInput (interface)", "RenderValidationMessageOptions": "src/system/validation-message.ts#RenderValidationMessageOptions (interface)", "ResolveOptions": "src/system/i18n-resolver.ts#ResolveOptions (interface)", @@ -777,7 +782,10 @@ "isPublicAudience": "src/system/book.zod.ts#isPublicAudience (function)", "minioStorageExample": "src/system/object-storage.zod.ts#minioStorageExample (const)", "objectFieldLabelKey": "src/system/i18n-resolver.ts#objectFieldLabelKey (function)", + "objectLabelKey": "src/system/i18n-resolver.ts#objectLabelKey (function)", + "operationMessageTranslationKey": "src/system/operation-message.ts#operationMessageTranslationKey (function)", "preferredLocaleFromHeader": "src/system/i18n-resolver.ts#preferredLocaleFromHeader (function)", + "renderOperationMessage": "src/system/operation-message.ts#renderOperationMessage (function)", "renderValidationMessage": "src/system/validation-message.ts#renderValidationMessage (function)", "resolveActionConfirm": "src/system/i18n-resolver.ts#resolveActionConfirm (function)", "resolveActionLabel": "src/system/i18n-resolver.ts#resolveActionLabel (function)", diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 33e0322978..767019d892 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -1196,6 +1196,22 @@ export function objectFieldLabelKey(objectName: string, fieldName: string): stri return `objects.${objectName}.fields.${fieldName}.label`; } +/** + * Dot-notation i18n key for an OBJECT's translated label — + * `objects..label`. + * + * The same location {@link translateObject} reads out of a bundle (through + * `lookupObjectField(bundle, name, 'label')`), spelled for consumers that hold + * an `II18nService` (which takes a key) rather than a `TranslationBundle` — + * exactly the relationship {@link objectFieldLabelKey} has to + * {@link resolveObjectFieldLabels}. Used by the engine to name an object in the + * caller's language when a data operation is refused (#7307), the way the field + * key already names the offending field (#3957). + */ +export function objectLabelKey(objectName: string): string { + return `objects.${objectName}.label`; +} + export function resolveObjectFieldLabels( data: TranslationData | undefined, objectName: string, diff --git a/packages/spec/src/system/index.ts b/packages/spec/src/system/index.ts index 8c4dd42c30..7f6cc704e9 100644 --- a/packages/spec/src/system/index.ts +++ b/packages/spec/src/system/index.ts @@ -66,6 +66,10 @@ export * from './translation.zod'; export * from './i18n-resolver'; // Localized templates for the built-in field-validation messages (#3957). export * from './validation-message'; +// Localized templates for OPERATION-level data refusals (#7307) — a write the +// engine declines as a whole, which names no field the caller supplied and so +// cannot honestly live under `validation.field.*`. +export * from './operation-message'; export * from './translation-typegen'; export * from './translation-skeleton'; export * from './collaboration.zod'; diff --git a/packages/spec/src/system/operation-message.test.ts b/packages/spec/src/system/operation-message.test.ts new file mode 100644 index 0000000000..8453f9be47 --- /dev/null +++ b/packages/spec/src/system/operation-message.test.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + BUILTIN_OPERATION_MESSAGES, + OPERATION_MESSAGE_FALLBACK_LOCALE, + operationMessageTranslationKey, + renderOperationMessage, +} from './operation-message'; + +/** + * #7307 — the catalog half. The engine call site is pinned in + * `packages/objectql/src/engine-delete-restricted-locale.test.ts`. + */ +describe('operation message catalog', () => { + const PARAMS = { object: '部门', dependentObject: '零星申请', field: '申报部门', count: 1 }; + + it('renders the caller locale, not English', () => { + const zh = renderOperationMessage({ messageKey: 'delete_restricted', params: PARAMS }, { locale: 'zh-CN' }); + expect(zh).toBe('该部门正被 1 条零星申请记录通过「申报部门」引用,请先删除或改派这些记录。'); + }); + + it('falls back to en for an unknown locale, and en is the declared fallback', () => { + expect(OPERATION_MESSAGE_FALLBACK_LOCALE).toBe('en'); + const out = renderOperationMessage({ messageKey: 'delete_restricted', params: PARAMS }, { locale: 'xx-YY' }); + expect(out).toBe(BUILTIN_OPERATION_MESSAGES.en.delete_restricted + .replace('{{object}}', '部门') + .replace('{{count}}', '1') + .replace('{{dependentObject}}', '零星申请') + .replace('{{field}}', '申报部门')); + }); + + it('matches a base language against a regional catalog key (zh → zh-CN)', () => { + expect(renderOperationMessage({ messageKey: 'delete_restricted', params: PARAMS }, { locale: 'zh' })) + .toContain('请先删除或改派这些记录'); + }); + + it('every locale defines every key en defines', () => { + const enKeys = Object.keys(BUILTIN_OPERATION_MESSAGES.en).sort(); + for (const [locale, catalog] of Object.entries(BUILTIN_OPERATION_MESSAGES)) { + expect({ locale, keys: Object.keys(catalog).sort() }).toEqual({ locale, keys: enKeys }); + } + }); + + it('no built-in template leaks a metadata-authoring hint into the user-facing sentence', () => { + // The whole point of the card: `deleteBehavior` is developer vocabulary and + // must not reach a toast in ANY locale. + for (const catalog of Object.values(BUILTIN_OPERATION_MESSAGES)) { + for (const template of Object.values(catalog)) { + expect(template).not.toMatch(/deleteBehavior|cascade/i); + } + } + }); + + it('the _required variant says the field cannot be cleared; the plain one does not', () => { + const req = renderOperationMessage({ messageKey: 'delete_restricted_required', params: PARAMS }, { locale: 'zh-CN' }); + expect(req).toContain('必填'); + expect(renderOperationMessage({ messageKey: 'delete_restricted', params: PARAMS }, { locale: 'zh-CN' })) + .not.toContain('必填'); + }); + + it('a deployment translation override wins over the built-in', () => { + const translate = (key: string) => + key === 'errors.delete_restricted' ? '不能删除:还有 {{count}} 条下级记录。' : key; + expect(renderOperationMessage({ messageKey: 'delete_restricted', params: PARAMS }, { locale: 'zh-CN', translate })) + .toBe('不能删除:还有 1 条下级记录。'); + }); + + it('an override key that misses (echoed back) falls through to the built-in', () => { + const translate = (key: string) => key; + expect(renderOperationMessage({ messageKey: 'delete_restricted', params: PARAMS }, { locale: 'zh-CN', translate })) + .toBe(BUILTIN_OPERATION_MESSAGES['zh-CN'].delete_restricted + .replace('{{object}}', '部门') + .replace('{{count}}', '1') + .replace('{{dependentObject}}', '零星申请') + .replace('{{field}}', '申报部门')); + }); + + it('a throwing i18n service does not turn a 409 into a 500', () => { + const translate = () => { throw new Error('service down'); }; + expect(renderOperationMessage({ messageKey: 'delete_restricted', params: PARAMS }, { locale: 'zh-CN', translate })) + .toContain('请先删除或改派这些记录'); + }); + + it('an unknown message key returns the key rather than an empty string', () => { + expect(renderOperationMessage({ messageKey: 'no_such_key' })).toBe('no_such_key'); + }); + + it('addresses overrides under `errors.`, NOT the field-validation namespace', () => { + expect(operationMessageTranslationKey('delete_restricted')).toBe('errors.delete_restricted'); + expect(operationMessageTranslationKey('delete_restricted')).not.toContain('validation.field'); + }); +}); diff --git a/packages/spec/src/system/operation-message.ts b/packages/spec/src/system/operation-message.ts new file mode 100644 index 0000000000..3040ce5774 --- /dev/null +++ b/packages/spec/src/system/operation-message.ts @@ -0,0 +1,180 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Operation Message Catalog + * + * The localized message templates for the data path's OPERATION-level + * refusals — a write the engine declines as a whole, rather than a constraint + * one field violated. Today that is the referential-integrity refusal + * (`409 DELETE_RESTRICTED`, `cascadeDeleteRelations`'s `restrict` branch); the + * catalog is the seat for the rest of the family as they are localized. + * + * ## Why this is a SEPARATE catalog from `validation-message.ts` + * + * The sibling catalog renders `FieldValidationError.message` and is addressed + * as `validation.field.`, because every one of its entries names a + * field and a constraint that field broke. A `DELETE_RESTRICTED` names neither: + * the offending field is on a DIFFERENT object from the one the caller acted + * on, the caller supplied no value, and there is no `fields[]` entry to hang it + * off. Filing it under `validation.field.*` would give a deployment an override + * key that lies about what it overrides, and would put an operation refusal in + * the namespace form UIs scan for per-field copy. Same machinery, honest + * address: `errors.` (see {@link operationMessageTranslationKey}). + * + * ## Why it exists at all (#7307) + * + * The restrict branch composed one English sentence with the API names + * concatenated in, and REST ships `error.message` verbatim in the flat 409 + * envelope — which Console renders as-is in a toast. A business user deleting a + * 「部门」 in a fully Chinese app read: + * + * `Cannot delete sys_business_unit (): 1 dependent + * os_tianshun_ehr_sporadic_application record(s) reference it via apply_dept + * … or set deleteBehavior:'cascade' on …` + * + * — an English sentence naming two tables and a column they have never seen, + * ending in a metadata-authoring instruction they cannot act on. This is the + * same defect #3957 fixed one layer down for field constraints, reached from + * the operation side, and it is fixed the same way: the catalog renders the + * user's half in the caller's locale against resolved LABELS, while the + * developer's half moves to `developerMessage`, which no user-facing surface + * reads. + * + * These strings are platform text, not authored metadata: they exist for every + * deployment whether or not anyone wrote a `translation`, so they ship as + * constants (same shape as `BUILTIN_VALIDATION_MESSAGES`) rather than through + * the extract-and-gate bundle pipeline, which tracks *declared metadata labels* + * and would read added keys as drift. + * + * ## Interpolation + * + * `{{name}}` placeholders, matching `II18nService.t()`'s convention. An unknown + * placeholder is left verbatim so a broken override is visible rather than + * silently blank — {@link interpolateValidationMessage} is shared with the + * sibling catalog so the two cannot drift on this. + */ + +import { resolveBundleLocale } from './i18n-resolver'; +import { interpolateValidationMessage } from './validation-message'; +import type { ValidationMessageTranslator } from './validation-message'; + +/** Prefix under which a deployment can override a built-in operation message. */ +export const OPERATION_MESSAGE_KEY_PREFIX = 'errors'; + +/** + * The i18n key a `messageKey` resolves under, e.g. `errors.delete_restricted`. + * A `translation` metadata item that defines this key overrides the built-in + * catalog for its locale. + */ +export function operationMessageTranslationKey(messageKey: string): string { + return `${OPERATION_MESSAGE_KEY_PREFIX}.${messageKey}`; +} + +/** + * Built-in templates, `locale → messageKey → template`. + * + * Locale keys match the platform bundles (`en`, `zh-CN`, `ja-JP`, `es-ES`); + * `en` is the last-resort fallback and is therefore the one locale that MUST + * define every key. + * + * The two `delete_restricted*` variants are one wire code with two sentences — + * the `_required` form is emitted when the child's foreign key is `required`, + * i.e. the case where reassigning is the only route because the reference + * cannot simply be cleared. Splitting the SENTENCE, never the code, is the same + * rule the field catalog states: `DELETE_RESTRICTED` stays one member of the + * ADR-0112 vocabulary that clients match on. + * + * Placeholders: `{{object}}` and `{{dependentObject}}` are LABELS in the + * caller's locale (the API names live on `developerMessage` and on the + * structured `object` / `dependentObject` fields), `{{field}}` is the + * referencing field's label, `{{count}}` the number of dependent records. + */ +export const BUILTIN_OPERATION_MESSAGES: Record> = { + en: { + delete_restricted: + 'This {{object}} is still referenced by {{count}} {{dependentObject}} record(s) through “{{field}}”. Delete or reassign them first.', + delete_restricted_required: + 'This {{object}} is still referenced by {{count}} {{dependentObject}} record(s) through “{{field}}”, which is required and cannot be cleared. Delete or reassign them first.', + }, + 'zh-CN': { + delete_restricted: + '该{{object}}正被 {{count}} 条{{dependentObject}}记录通过「{{field}}」引用,请先删除或改派这些记录。', + delete_restricted_required: + '该{{object}}正被 {{count}} 条{{dependentObject}}记录通过「{{field}}」引用,且该字段为必填、无法清空,请先删除或改派这些记录。', + }, + 'ja-JP': { + delete_restricted: + 'この{{object}}は {{count}} 件の{{dependentObject}}レコードから「{{field}}」で参照されています。先にそれらを削除するか、参照先を変更してください。', + delete_restricted_required: + 'この{{object}}は {{count}} 件の{{dependentObject}}レコードから「{{field}}」で参照されています。この項目は必須のため空にできません。先にそれらを削除するか、参照先を変更してください。', + }, + 'es-ES': { + delete_restricted: + '{{count}} registro(s) de {{dependentObject}} todavía hacen referencia a este {{object}} mediante «{{field}}». Elimínelos o reasígnelos primero.', + delete_restricted_required: + '{{count}} registro(s) de {{dependentObject}} todavía hacen referencia a este {{object}} mediante «{{field}}», un campo obligatorio que no puede vaciarse. Elimínelos o reasígnelos primero.', + }, +}; + +/** Locale whose catalog is guaranteed complete and used as the last resort. */ +export const OPERATION_MESSAGE_FALLBACK_LOCALE = 'en'; + +export interface RenderOperationMessageInput { + /** Catalog key, e.g. `delete_restricted`. */ + messageKey: string; + /** Placeholder values — labels, counts. */ + params?: Record; +} + +export interface RenderOperationMessageOptions { + /** BCP-47 locale; defaults to `en`. */ + locale?: string; + /** Deployment override hook — an `II18nService.t`-compatible lookup. */ + translate?: ValidationMessageTranslator; +} + +/** + * Render one built-in operation message in the caller's locale. + * + * Resolution order (identical to {@link renderValidationMessage}, deliberately + * — two resolution orders for two catalogs is how a deployment's overrides + * start behaving differently depending on which layer refused): + * 1. `translate('errors.', locale)` — a deployment's + * `translation` override. A miss is detected by the II18nService contract + * of echoing the key back. + * 2. the built-in catalog for the locale (BCP-47 matched). + * 3. the built-in catalog for `en`. + * 4. the messageKey itself — only reachable for a key absent from even the + * English catalog, i.e. a coding error; still returns something a human + * can act on rather than an empty string. + */ +export function renderOperationMessage( + input: RenderOperationMessageInput, + opts: RenderOperationMessageOptions = {}, +): string { + const locale = opts.locale ?? OPERATION_MESSAGE_FALLBACK_LOCALE; + const params = input.params ?? {}; + + if (opts.translate) { + const key = operationMessageTranslationKey(input.messageKey); + let override: string | undefined; + try { + override = opts.translate(key, locale, params); + } catch { + // A misbehaving i18n service must never turn a 409 into a 500. + override = undefined; + } + if (typeof override === 'string' && override.length > 0 && override !== key) { + return interpolateValidationMessage(override, params); + } + } + + const matched = resolveBundleLocale(BUILTIN_OPERATION_MESSAGES, locale); + const template = (matched !== undefined + ? BUILTIN_OPERATION_MESSAGES[matched][input.messageKey] + : undefined) + ?? BUILTIN_OPERATION_MESSAGES[OPERATION_MESSAGE_FALLBACK_LOCALE][input.messageKey]; + + if (template === undefined) return input.messageKey; + return interpolateValidationMessage(template, params); +}