Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/27559-extension-rule-tenant-rewrite-wins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/objectql': minor
'@objectstack/metadata-protocol': minor
'@objectstack/spec': patch
---

fix(i18n): a tenant's rewrite of a validation rule that an object extension contributes is served as written in every locale, on the refusal and on `/meta` (#27559)

Clause-②: yes (widening) — `SchemaRegistry` (`@objectstack/objectql`) gains the public read `getPackagedObjectBase(name)`
Metadata-protocol: semantic

#27221 made a tenant's reworded rule message beat the package's translation, but only for rules the object's owning package declares. A rule a second package adds through `objectExtensions` was compared with nothing, so when a tenant overlaid the object and reworded that rule, a reader in any locale the extension's bundle carries still got the extension's translation, on the rejected write's error and on the rule `/meta` serves.

- **Now.** A rule's packaged message is the message of whichever code package shipped that rule name: the owner, or a package that extends the object. Both readers compare against that one packaged rule set, so a tenant's rewrite of an extension rule is served and returned as written, and an extension rule the tenant left alone is still translated.
- **The fold is unchanged.** Validations stay additive (ADR-0029 D9.2a): when the tenant's overlay carries its reworded copy of an extension rule, the extension's own rule still runs beside it, and that copy, unchanged, is still translated.
- **`SchemaRegistry.getPackagedObjectBase(name)`** answers the packaged owner's declaration with each code extender's `validations` appended (by reference when no extender ships a rule; `undefined` where `getPackagedObjectOwner` is). Only `validations` widens: `label`, `pluralLabel` and `description` stay the owner's, so an extension's label keeps winning over the owner's catalog. `ObjectStackProtocolImplementation.getPackagedObjectBase` and the rule evaluator's packaged lookup both read it.
- **`@objectstack/spec`**: the `TranslateDocumentOptions.packagedBase` docs say what an object's packaged base now carries. No type or behaviour changes.
20 changes: 11 additions & 9 deletions content/docs/ui/translations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -226,15 +226,17 @@ for one) is served as written.
An object's validation rule messages follow it too, on both places a reader
sees them: the error a rejected write returns, and the rule served with the
object (`/meta`, and the forms built from it). The rule's `message` is compared
with the message of the rule of the same `name` in the owning package's
declaration, a `conditional` rule's `then` / `otherwise` branch by its own name.
An organization that overlays a packaged object and rewords a rule's message,
keeping its name, gets that sentence as written in every locale; the rules it
left alone are still translated, and both places always show the same sentence.
Unlike a widget or a bulk action, a rule the owning package does not declare
(one an object extension adds, or one the organization added) is **still
translated** under its own name, `objects.<name>._validations.<rule>.message`:
there is no shipped message for it to differ from.
with the message of the rule of the same `name` that a package shipped, a
`conditional` rule's `then` / `otherwise` branch by its own name. That is the
owning package's declaration or, for a rule an
[object extension](/docs/data-modeling/object-extensions) adds, the extending
package's. An organization that overlays a packaged object and rewords a rule's
message, keeping its name, gets that sentence as written in every locale. The
rules it left alone are still translated, and both places always show the same
sentence. Unlike a widget or a bulk action, a rule
no package ships (one the organization added) is **still translated** under its
own name, `objects.<name>._validations.<rule>.message`: there is no shipped
message for it to differ from.

## Organizing the files

Expand Down
13 changes: 12 additions & 1 deletion packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8948,6 +8948,15 @@ export class ObjectStackProtocolImplementation implements
* "does a code package ship this?" test (`isCodeArtifactBody`) applied to
* the OWNER contributor alone.
*
* [#27559] Its `validations` are the one exception to "owner only": the
* registry's `getPackagedObjectBase` appends the rules each CODE extender
* ships, because a rule's packaged message is whichever code layer shipped
* that rule name, and the rule evaluator's `packagedRuleLookup` reads the
* same answer, so `/meta` and the refusal judge a tenant's rewrite against
* one packaged rule set. The scalars above stay the owner's. A registry
* double that predates that method is read through
* `getPackagedObjectOwner`, the owner alone.
*
* Returns `undefined` — meaning "no packaged baseline, do not infer
* anything" — for a runtime/tenant-authored object (no code owner), an
* unknown name, and a partial registry double. The caller's contract is
Expand All @@ -8956,8 +8965,10 @@ export class ObjectStackProtocolImplementation implements
getPackagedObjectBase(name: string): unknown {
if (typeof name !== 'string' || name === '') return undefined;
const registry = (this.engine as any)?.registry;
if (!registry || typeof registry.getPackagedObjectOwner !== 'function') return undefined;
if (!registry) return undefined;
try {
if (typeof registry.getPackagedObjectBase === 'function') return registry.getPackagedObjectBase(name);
if (typeof registry.getPackagedObjectOwner !== 'function') return undefined;
return registry.getPackagedObjectOwner(name)?.definition;
} catch {
// Same rule as the fold above: a read over in-memory contributors
Expand Down
25 changes: 16 additions & 9 deletions packages/objectql/src/engine-validation-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,25 +93,32 @@ const MAX_VALIDATION_DEPTH = 10;
* [ADR-0029 D9.2a] The packaged (code-shipped) rule of a name on `objectName`,
* for the rule evaluator's divergence test.
*
* The declaration is the packaged OWNER's (`getPackagedObjectOwner`, pre-fold),
* the same body `/meta` hands `translateObject` as `packagedBase` through
* `getPackagedObjectBase`, so both readers compare a served message against one
* packaged rule. Rules are keyed by `name`, each `conditional` branch under its
* own name; the first rule of a name wins, as on `/meta`.
* The rules are the registry's packaged rule set (`getPackagedObjectBase`: the
* packaged owner's declaration with its code extenders' rules appended, #27559),
* the same body `/meta` hands `translateObject` as `packagedBase` through the
* protocol's `getPackagedObjectBase`, so both readers compare a served message
* against one packaged rule. Rules are keyed by `name`, each `conditional`
* branch under its own name; the first rule of a name wins, as on `/meta`.
*
* `undefined` — nothing inferred, the catalog applies — when no packaged owner
* is registered for the object, or the registry cannot answer (a partial
* double). The index is built on the first lookup, which a write that refuses
* nothing never makes.
* double; one that predates `getPackagedObjectBase` is read through
* `getPackagedObjectOwner`, the owner alone). The index is built on the first
* lookup, which a write that refuses nothing never makes.
*/
function packagedRuleLookup(
registry: ValidationMessageHost['registry'] | undefined,
objectName: string,
): ((ruleName: string) => unknown) | undefined {
if (typeof registry?.getPackagedObjectOwner !== 'function') return undefined;
let definition: unknown;
try {
definition = registry.getPackagedObjectOwner(objectName)?.definition;
if (typeof registry?.getPackagedObjectBase === 'function') {
definition = registry.getPackagedObjectBase(objectName);
} else if (typeof registry?.getPackagedObjectOwner === 'function') {
definition = registry.getPackagedObjectOwner(objectName)?.definition;
} else {
return undefined;
}
} catch {
// A read over in-memory contributors must not turn a 400 into a 500.
return undefined;
Expand Down
70 changes: 66 additions & 4 deletions packages/objectql/src/engine-validation-tenant-rewrite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@
* from the owner's declaration by construction — must still answer its own
* package's translation: it has no packaged message to diverge from.
*
* #27559 — the same holds for a rule the second package contributes: when the
* tenant's overlay carries that rule reworded (the Studio GET → edit → PUT of
* the folded object), the packaged message to compare with is the EXTENSION
* package's, so the tenant's sentence is served as written. The fold still
* appends the extension's own copy beside it (validations stay additive,
* ADR-0029 D9.2a), and that copy, unchanged, is still translated.
*
* Both readers are driven off ONE real registry: the refusal of a real write
* (the engine's validator), and the served object as `/meta` translates it —
* `translateObject` over the resolved object, with the packaged base the
Expand Down Expand Up @@ -148,15 +155,21 @@ describe('ADR-0029 D9.2a a tenant-rewritten rule message, on the refusal and on
let protocol: any;

/** Boot the packaged object, then (optionally) the tenant's overlay layer. */
async function boot(overlay: boolean) {
async function boot(overlay: boolean, extensionMessage?: string) {
engine = new ObjectQL();
engine.registerDriver(makeDriver(), true);
await engine.init();
engine.setI18nService(servedI18n(BUNDLE) as any);
engine.registry.registerObject(body(PACKAGED.returned_needs_note) as any, PKG);
engine.registry.registerObject(EXTENSION as any, EXT_PKG, undefined, 'extend');
if (overlay) {
engine.registry.registerObject({ ...body(REWRITE), _provenance: 'org' } as any, PKG, undefined, 'overlay');
const overlayBody = body(REWRITE);
// The overlay as a Studio round-trip of the FOLDED object stores it: the
// extension's rule rides along, here with its message reworded.
if (extensionMessage !== undefined) {
overlayBody.validations.push({ ...EXTENSION.validations[0], message: extensionMessage });
}
engine.registry.registerObject({ ...overlayBody, _provenance: 'org' } as any, PKG, undefined, 'overlay');
}
protocol = new ObjectStackProtocolImplementation(engine as any);
}
Expand All @@ -169,7 +182,9 @@ describe('ADR-0029 D9.2a a tenant-rewritten rule message, on the refusal and on
try {
await engine.insert(OBJ, data, { context: { locale: preferredLocaleFromHeader(header) } } as any);
} catch (e: any) {
return String(e?.fields?.[0]?.message ?? e?.message);
// Every violation, so a rule folded in twice shows as two sentences.
const fields: Array<{ message?: unknown }> = Array.isArray(e?.fields) ? e.fields : [];
return fields.length > 0 ? fields.map((f) => String(f.message)).join(' | ') : String(e?.message);
}
throw new Error('expected the write to be rejected');
}
Expand Down Expand Up @@ -205,9 +220,13 @@ describe('ADR-0029 D9.2a a tenant-rewritten rule message, on the refusal and on
const served = (engine.registry.getObject(OBJ) as any).validations.map((r: any) => r.name);
expect(served).toEqual(['returned_needs_note', 'done_needs_owner', 'ext_blocked_needs_reason']);
expect((engine.registry.getObject(OBJ) as any).validations[0].message).toBe(REWRITE);
// [#27559] The packaged rule set is the owner's rules plus the code
// extender's — every rule a code layer ships, each with its shipped message.
const packaged = protocol.getPackagedObjectBase(OBJ) as any;
expect(packaged.validations.map((r: any) => r.name)).toEqual(['returned_needs_note', 'done_needs_owner']);
expect(packaged.validations.map((r: any) => r.name))
.toEqual(['returned_needs_note', 'done_needs_owner', 'ext_blocked_needs_reason']);
expect(packaged.validations[0].message).toBe(PACKAGED.returned_needs_note);
expect(packaged.validations[2].message).toBe(PACKAGED.ext_blocked_needs_reason);
});

it('serves and refuses with the rewrite as written, and still translates the untouched and extension rules', async () => {
Expand All @@ -224,6 +243,49 @@ describe('ADR-0029 D9.2a a tenant-rewritten rule message, on the refusal and on
});
});

describe('[#27559] with the tenant overlay rewording the extension rule', () => {
const EXT_REWRITE = 'Tell us what blocks this duty.';
beforeEach(async () => { await boot(true, EXT_REWRITE); });

/** Every served message of `ext_blocked_needs_reason`, in fold order. */
function servedExtension(header: string): string[] {
const doc = translateObject(engine.registry.getObject(OBJ) as any, BUNDLE as any, {
locale: preferredLocaleFromHeader(header),
fallbackChain: ['en'],
packagedBase: protocol.getPackagedObjectBase(OBJ),
}) as any;
return doc.validations
.filter((r: any) => r.name === 'ext_blocked_needs_reason')
.map((r: any) => r.message);
}

it('serves and refuses with the rewrite as written, and still translates the extension copy beside it', async () => {
// The fold is additive for validations: the overlay's reworded copy, then
// the extension's own rule. Only the reworded one diverges from the
// extension package's packaged message.
const out: Record<string, unknown> = {};
for (const header of ['zh-CN', 'zh', 'en']) {
out[header] = {
served: servedExtension(header),
refusal: await refusal(header, { id: 'r3', status: 'blocked' }),
};
}
expect(out).toEqual({
'zh-CN': { served: [EXT_REWRITE, ZH.ext_blocked_needs_reason], refusal: `${EXT_REWRITE} | ${ZH.ext_blocked_needs_reason}` },
zh: { served: [EXT_REWRITE, ZH.ext_blocked_needs_reason], refusal: `${EXT_REWRITE} | ${ZH.ext_blocked_needs_reason}` },
en: {
served: [EXT_REWRITE, PACKAGED.ext_blocked_needs_reason],
refusal: `${EXT_REWRITE} | ${PACKAGED.ext_blocked_needs_reason}`,
},
});
});

it('leaves the owner rules as #27543 answers them', async () => {
expect(await refusal('zh-CN', { id: 'r1', status: 'returned' })).toBe(REWRITE);
expect(await refusal('zh-CN', { id: 'r2', status: 'done' })).toBe(ZH.done_needs_owner);
});
});

describe('control: no overlay', () => {
beforeEach(async () => { await boot(false); });

Expand Down
60 changes: 60 additions & 0 deletions packages/objectql/src/protocol-packaged-object-base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,63 @@ describe('#8284 getPackagedObjectBase — the packaged declaration, pre-fold', (
expect(noRegistry.getPackagedObjectBase(OBJ)).toBeUndefined();
});
});

describe('#27559 getPackagedObjectBase — the packaged RULE set is the owner plus its code extenders', () => {
const EXT_PKG = 'app.showcase-success';
const rule = (name: string, message: string) => ({
type: 'script', name, message, condition: 'record.name == null', fields: ['name'],
});

function registerRuleExtension(registry: SchemaRegistry, rules: unknown[], packageId = EXT_PKG) {
registry.registerObject(
{ name: OBJ, label: EXTENSION_LABEL, validations: rules } as any,
packageId,
undefined,
'extend',
210,
);
}

it('appends each code extender\'s rules to the owner\'s, and keeps the owner\'s scalars (#8037)', () => {
const s = makeSession();
registerPackaged(s.registry);
s.registry.registerObject(
{ name: OBJ, label: 'Ignored', validations: [rule('own_rule', 'Owner says.')] } as any,
PKG,
);
registerRuleExtension(s.registry, [rule('ext_rule', 'Extension says.')]);

const base = baseOf(s.protocol);
expect(base.validations.map((r: any) => [r.name, r.message])).toEqual([
['own_rule', 'Owner says.'],
['ext_rule', 'Extension says.'],
]);
// ⛔ Not by widening the object scalars' base: the extension's label must
// still read as diverged from the packaged one, so it keeps winning.
expect(base.label).toBe('Ignored');
expect((s.registry.getObject(OBJ) as any).label).toBe(EXTENSION_LABEL);
});

it('stays the owner declaration BY REFERENCE when no extender ships a rule', () => {
const s = makeSession();
registerPackaged(s.registry);
registerExtension(s.registry);
expect(baseOf(s.protocol)).toBe(s.registry.getPackagedObjectOwner(OBJ)?.definition);
});

it('does not move under a tenant overlay that rewords an extender rule', () => {
const s = makeSession();
registerPackaged(s.registry);
registerRuleExtension(s.registry, [rule('ext_rule', 'Extension says.')]);
s.registry.registerObject(
{
name: OBJ, label: 'Customer', validations: [rule('ext_rule', 'Tenant says.')],
_packageId: 'sys_metadata', _provenance: 'org',
} as any,
'sys_metadata',
undefined,
'overlay',
);
expect(baseOf(s.protocol).validations.map((r: any) => r.message)).toEqual(['Extension says.']);
});
});
38 changes: 38 additions & 0 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5074,6 +5074,44 @@ export class SchemaRegistry {
return owner && isCodeArtifactBody(owner.definition) ? owner : undefined;
}

/**
* [ADR-0029 D9.2a, #27559] The packaged base a served object is compared
* against: the {@link getPackagedObjectOwner} declaration, with the
* `validations` of every CODE `extend` contributor appended in fold order.
*
* The packaged message of a rule is the message of whichever code layer
* ships that rule name — the owner or a code extender — because the catalog
* translates exactly what those layers ship. Reading the owner alone left an
* extender's rule with nothing to compare a tenant's rewrite with, so the
* catalog won over it on `/meta` and on the refusal alike. Both readers take
* their packaged rules from here (`getPackagedObjectBase` in
* `@objectstack/metadata-protocol`, `packagedRuleLookup` in this package),
* so they cannot split again.
*
* ⛔ ONLY `validations` widens. The scalars stay the owner's: an extension's
* `label` / `description` must keep reading as diverged from the packaged
* one, or the catalog takes back the case #8037 filed.
*
* Returns the owner's definition BY REFERENCE when no code extender ships a
* rule, and `undefined` exactly where {@link getPackagedObjectOwner} does.
*/
getPackagedObjectBase(name: string): ServiceObject | undefined {
const owner = this.getPackagedObjectOwner(name);
if (!owner) return undefined;
const fqn = this.resolveObjectKey(name);
let rules: unknown[] | undefined;
for (const contrib of (fqn === undefined ? undefined : this.objectContributors.get(fqn)) ?? []) {
if (contrib.ownership !== 'extend' || !isCodeArtifactBody(contrib.definition)) continue;
const list = contrib.definition.validations;
if (!Array.isArray(list) || list.length === 0) continue;
rules ??= [...(owner.definition.validations ?? [])];
rules.push(...list);
}
return rules === undefined
? owner.definition
: { ...owner.definition, validations: rules as ServiceObject['validations'] };
}

/**
* [ADR-0029 D9.7] Remove the tenant OVERLAY layer of an object, leaving the
* packaged owner exactly where it is — the layer-addressed sibling of
Expand Down
Loading
Loading