diff --git a/.changeset/28205-home-amount-both-faults.md b/.changeset/28205-home-amount-both-faults.md new file mode 100644 index 00000000000..abb93a98ffb --- /dev/null +++ b/.changeset/28205-home-amount-both-faults.md @@ -0,0 +1,10 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): a `currencyConfig.homeAmount` home or rate field that lacks `readonly: true` and declares an `expression` is told both faults in one refusal + +Clause-②: no +Metadata-protocol: none + +An object whose `homeAmount.field` or `homeAmount.rateField` names a field that lacks `readonly: true` AND declares an `expression` was refused with only the `readonly` fault, so the author added `readonly: true` and met the `expression` fault on the next parse. The one issue at that pointer now names both faults and both remedies: add `readonly: true` to the field and delete its `expression`. The accept set is unchanged; a field with only one of the two faults is told exactly what it was told before. diff --git a/packages/spec/src/data/currency-config-home-amount.test.ts b/packages/spec/src/data/currency-config-home-amount.test.ts index 042df547568..ef89f672799 100644 --- a/packages/spec/src/data/currency-config-home-amount.test.ts +++ b/packages/spec/src/data/currency-config-home-amount.test.ts @@ -233,6 +233,16 @@ describe('the object judges what `homeAmount` names', () => { expect(issues.map((i) => i.path)).toEqual([`${AT}.${key}`]); expect(issues[0]!.message).toContain('declares an `expression`'); }); + + it.each(['amount_home', 'amount_rate'] as const)('names both faults in one issue when `%s` has neither mark', (name) => { + const issues = objectIssues(invoice({ [name]: { ...BASE_FIELDS[name], readonly: false, expression: 'record.amount' } })); + const key = name === 'amount_home' ? 'field' : 'rateField'; + expect(issues.map((i) => i.path)).toEqual([`${AT}.${key}`]); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.message).toContain('does not declare `readonly: true`'); + expect(issues[0]!.message).toContain('declares an `expression`'); + expect(issues[0]!.message).toContain(`Add \`readonly: true\` to \`${name}\` and delete its \`expression\``); + }); }); it('refuses `homeAmount` on a `formula` amount, at `homeAmount`, judging no pointer', () => { diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 67228dde3fe..7a8be354498 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -2034,16 +2034,22 @@ function refuseDishonestHomeAmount( // non-system write of it, and no `expression` competes with the engine. const refuseUnlessPlatformWritten = (key: 'field' | 'rateField', target: string, what: string): void => { const t = map[target]; - if (t?.readonly === true && t.expression === undefined) return; + const writable = t?.readonly !== true; + const computed = t?.expression !== undefined; + if (!writable && !computed) return; + // Both faults on one field are named in one issue, so the author fixes + // the field once instead of meeting the second fault on the next parse. + const writableFault = 'does not declare `readonly: true`, so a client write could overwrite what the engine stored'; + const computedFault = 'declares an `expression`, so the engine would store a second, computed value in the same column'; ctx.addIssue({ code: 'custom', path: [...base, key], message: `${at}.${key}\` names \`${target}\`, ${what} only the engine writes, but it ` - + (t?.readonly === true - ? 'declares an `expression`, so the engine would store a second, computed value in the same column. ' - + `Delete \`expression\` from \`${target}\`.` - : 'does not declare `readonly: true`, so a client write could overwrite what the engine stored. ' - + `Add \`readonly: true\` to \`${target}\`.`), + + (writable && computed + ? `${writableFault}, and it ${computedFault}. Add \`readonly: true\` to \`${target}\` and delete its \`expression\`.` + : writable + ? `${writableFault}. Add \`readonly: true\` to \`${target}\`.` + : `${computedFault}. Delete \`expression\` from \`${target}\`.`), }); };