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
9 changes: 9 additions & 0 deletions .changeset/handwritten-errmap-fix-before-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@objectstack/spec': patch
---

Reorder the three hand-written `unrecognized_keys` error maps so the fix is read before the explanation (#6416, applying #5955's ruling).

`strictVisibilityError` (`shared/visibility.ts`), `strictWidgetAnalyticsError` (`ui/dashboard.zod.ts`) and `strictTenancyError` (`data/object.zod.ts`) are independent `$ZodErrorMap` functions rather than `strictUnknownKeyError` call sites, so #5955's reorder of the shared template did not reach them and #5593's `strictObject` migration cannot either. Each reproduced the exact shape #5955 was filed against: a non-actionable explanatory sentence sitting between the offending key and the prescription that fixes it, which on the single-line renders several consumers use (`os validate`'s `• where: message`, CI logs, `validateFlowTriggerReadiness`) pushed the fix out of the part an author actually reads.

Every message now emits front matter (which key is wrong) → every fix channel (the `visibleWhen` alias pointer; the ADR-0021 dataset / objectui-quarantine / #5022 drill branches; the per-key `tenancy` tombstone bullets) → the explanatory sentence last. Nothing is deleted and nothing becomes conditional — each sentence is still emitted verbatim, once per message, and all seven message variants are byte-identical in length and character multiset to their previous spelling. No input changes acceptance: these maps only shape the text of an already-failing parse, and the `visibility.ts` alias tables are untouched.
67 changes: 67 additions & 0 deletions packages/spec/src/data/object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1521,6 +1521,73 @@ describe('TenancyConfigSchema — #2763 strategy/crossTenantAccess removal', ()
});
});

/**
* Message ORDER on `strictTenancyError` (#6416, applying #5955's ruling).
*
* A hand-written `$ZodErrorMap`: it never calls `strictUnknownKeyError`, so
* #5955's reorder of the shared template did not reach it, and it is not one of
* the 44 direct call sites #5593 migrates to `strictObject` either. Its
* explanatory sentence is the standing two-modes explainer, and its FIX channel
* is the per-key ` • ` bullets built just above it — the tombstone that tells
* an upgrading author what to write instead. Those bullets used to sit BEHIND
* ~160 characters of standing background, which is past the front of the
* single-line renders several consumers use (`os validate`'s `• where: message`,
* CI logs).
*
* ORDER pins, not presence checks. Every `toContain` in the block above stays
* green under either order — that is exactly why they cannot carry this fact.
*/
describe('strictTenancyError message order — bullets before the explainer (#6416)', () => {
const EXPLAINER =
'The two supported tenancy modes are: database-per-tenant = environment-level ' +
'deployment (no object config); row-level isolation = `tenancy.enabled` + ' +
'`tenancy.tenantField`.';

const messageFor = (body: Record<string, unknown>) => {
const res = TenancyConfigSchema.safeParse({ enabled: true, ...body });
expect(res.success).toBe(false);
const unknown = res.error!.issues.find((i) => i.code === 'unrecognized_keys');
expect(unknown).toBeDefined();
return unknown!.message;
};

it('names the wrong key first, then the tombstone bullet, then the explainer', () => {
const m = messageFor({ strategy: 'isolated' });
// 1. which key is wrong — and nothing before it
expect(m.startsWith('Unrecognized key(s) on `tenancy`: `strategy`.\n')).toBe(true);
// 2. the fix channel: the per-key bullet, on the line right after
expect(m).toContain('\n • `tenancy.strategy` was removed from @objectstack/spec after v15.0');
// 3. the explainer, verbatim, last — moved, never dropped
expect(m.indexOf('Delete the key.')).toBeLessThan(m.indexOf(EXPLAINER));
expect(m.endsWith(` ${EXPLAINER}`)).toBe(true);
});

it('keeps EVERY per-key bullet ahead of the explainer, not just the first', () => {
// One issue names every offending key, so the explainer is a per-MESSAGE
// sentence: a reorder that put it after the first bullet would bury the rest.
const m = messageFor({ strategy: 'isolated', crossTenantAccess: true, tenantfield: 'org_id' });
for (const bullet of [
'`tenancy.strategy` was removed',
'`tenancy.crossTenantAccess` was removed',
'`tenantfield` is not a `tenancy` key.',
]) {
expect(m).toContain(bullet);
expect(m.indexOf(bullet), bullet).toBeLessThan(m.indexOf(EXPLAINER));
}
expect(m.split(EXPLAINER)).toHaveLength(2);
expect(m.endsWith(` ${EXPLAINER}`)).toBe(true);
});

it('is a full-message pin for the plain unknown-key case', () => {
// Any stray separator, dropped newline or duplicated clause fails here.
expect(messageFor({ tenantfield: 'org_id' })).toBe(
'Unrecognized key(s) on `tenancy`: `tenantfield`.\n' +
' • `tenantfield` is not a `tenancy` key. ' +
EXPLAINER,
);
});
});

describe('isTenancyDisabled — platform-global posture predicate (#3249, ADR-0066)', () => {
it('is true only for an explicit tenancy.enabled === false', () => {
expect(isTenancyDisabled({ name: 'sys_license', tenancy: { enabled: false } })).toBe(true);
Expand Down
26 changes: 22 additions & 4 deletions packages/spec/src/data/object.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,24 @@ const TENANCY_RETIRED_KEY_GUIDANCE: Record<string, string> = {
* `strategy`/`crossTenantAccess` or a typo — is a loud, *fixable* parse error
* instead of a silent strip (#1535), and a retired key's error carries its
* upgrade prescription. Every other issue code defers to zod's default.
*
* ## Message order: the fix comes before the explainer (#5955 / #6416)
*
* ```text
* Unrecognized key(s) on `tenancy`: `k1`. ← which key is wrong
* • {per-key tombstone / "not a `tenancy` key"} ← the fix
* The two supported tenancy modes are: … ← the standing explainer
* ```
*
* Same emission order the shared `strictUnknownKeyError` template took in
* #5955 — bullets first, the surface-level sentence appended to the last one.
* A hand-written `$ZodErrorMap` is reachable by neither that fix nor #5593's
* `strictObject` migration, so #6416 applies the ruling here directly. The
* two-modes explainer used to sit between the key statement and the bullets,
* which on the single-line renders several consumers use (`os validate`'s
* `• where: message`, CI logs) buried each key's actual prescription behind
* ~160 characters of standing background. Nothing is dropped: the explainer is
* still emitted verbatim, just last.
*/
const strictTenancyError: z.core.$ZodErrorMap = (issue) => {
if (issue.code !== 'unrecognized_keys') return undefined;
Expand All @@ -429,11 +447,11 @@ const strictTenancyError: z.core.$ZodErrorMap = (issue) => {
TENANCY_RETIRED_KEY_GUIDANCE[key] ?? `\`${key}\` is not a \`tenancy\` key.`,
);
return (
`Unrecognized key(s) on \`tenancy\`: ${keys.map((k) => `\`${k}\``).join(', ')}. ` +
'The two supported tenancy modes are: database-per-tenant = environment-level ' +
`Unrecognized key(s) on \`tenancy\`: ${keys.map((k) => `\`${k}\``).join(', ')}.\n` +
lines.map((l) => ` • ${l}`).join('\n') +
' The two supported tenancy modes are: database-per-tenant = environment-level ' +
'deployment (no object config); row-level isolation = `tenancy.enabled` + ' +
'`tenancy.tenantField`.\n' +
lines.map((l) => ` • ${l}`).join('\n')
'`tenancy.tenantField`.'
);
};

Expand Down
30 changes: 25 additions & 5 deletions packages/spec/src/shared/visibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,22 +90,42 @@ function looksLikeVisibilityKey(key: string): boolean {
* .strict()
* .transform(normalizeVisibleWhen)
* ```
*
* ## Message order: the fix comes before the history (#5955 / #6416)
*
* ```text
* Unrecognized key(s) on this view/page schema: `k1`. ← which key is wrong
* [ If this is the conditional-visibility predicate … ] ← the fix
* Before ADR-0089 D3a these were dropped silently … ← why it used to be silent
* ```
*
* This map is a hand-written `$ZodErrorMap`, so #5955's fix to the shared
* `strictUnknownKeyError` template could not reach it and #5593's
* `strictObject` migration cannot either — #6416 applies the same ruling here.
* The history sentence used to sit between the key statement and the alias
* pointer, which is the position several consumers render on ONE line
* (`os validate`'s `• where: message`, CI logs, and
* `validateFlowTriggerReadiness`, which flattens the newlines): an author —
* often an AI — reads the front of that line and acts on it, so the canonical
* key has to be there. Nothing is dropped and nothing is conditional; the
* sentence is still emitted verbatim, just last.
*/
export const strictVisibilityError: z.core.$ZodErrorMap = (issue) => {
if (issue.code !== 'unrecognized_keys') return undefined;
const keys = (issue as { keys?: readonly string[] }).keys ?? [];
const list = keys.map((k) => `\`${k}\``).join(', ');
const base =
`Unrecognized key(s) on this view/page schema: ${list}. ` +
const front = `Unrecognized key(s) on this view/page schema: ${list}.`;
const history =
`Before ADR-0089 D3a these were dropped silently, shipping inert metadata; ` +
`a mis-layered or stale key is now a loud parse error.`;
if (keys.some(looksLikeVisibilityKey)) {
return (
base +
front +
' If this is the conditional-visibility predicate, the canonical key is ' +
'`visibleWhen` (ADR-0089) — `visibleOn` (view form) and `visibility` (page ' +
'component) are still accepted as deprecated aliases.'
'component) are still accepted as deprecated aliases. ' +
history
);
}
return base;
return `${front} ${history}`;
};
85 changes: 85 additions & 0 deletions packages/spec/src/ui/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,91 @@ describe('DashboardWidgetSchema (dataset-bound)', () => {
});
});

/**
* Message ORDER on `strictWidgetAnalyticsError` (#6416, applying #5955's ruling).
*
* This map is a hand-written `$ZodErrorMap`, so neither #5955 (which moved the
* history sentence to the end inside the shared `strictUnknownKeyError`) nor
* #5593 (which migrates the direct call sites to `strictObject`) reached it. It
* carried the same defect: a ~150-char history sentence sitting BETWEEN the
* offending key and whichever of the three prescription branches fixes it —
* the ADR-0021 dataset migration, the objectui `component`/`data` quarantine,
* and the #5022 drill near-key answer — pushing all three past the front of the
* single-line renders several consumers use.
*
* ORDER pins, not presence checks: the reorder deletes nothing, so every
* `toContain` in the block above stays green either way. An edit that folds the
* sentence back into the middle passes all of them and fails here.
*/
describe('strictWidgetAnalyticsError message order — fix before history (#6416)', () => {
const HISTORY =
'Undeclared top-level keys were dropped silently before strict validation, ' +
'shipping inert metadata; a stale or mis-layered key is now a loud parse error.';

const base = { id: 'w1', type: 'metric', dataset: 'sales', values: ['revenue'] };
const messageFor = (extra: Record<string, unknown>) => {
const res = DashboardWidgetSchema.safeParse({ ...base, ...extra } as any);
expect(res.success).toBe(false);
const unknown = res.error!.issues.find((i) => i.code === 'unrecognized_keys');
expect(unknown).toBeDefined();
return unknown!.message;
};

const orderPin = (label: string, extra: Record<string, unknown>, key: string, fix: string) => {
it(label, () => {
const m = messageFor(extra);
// 1. which key is wrong — and nothing before it
expect(m.startsWith(`Unrecognized key(s) on this dashboard widget: \`${key}\`.`)).toBe(true);
// 2. the fix, ahead of the history
expect(m).toContain(fix);
expect(m.indexOf(fix)).toBeLessThan(m.indexOf(HISTORY));
// 3. the history sentence, verbatim, last — moved, never dropped
expect(m.endsWith(` ${HISTORY}`)).toBe(true);
});
};

orderPin(
'legacy inline-analytics branch: the ADR-0021 dataset prescription comes first',
{ categoryField: 'stage' },
'categoryField',
'The pre-ADR-0021 inline analytics shape',
);

orderPin(
'quarantine branch: the objectui-internal verdict comes first',
{ component: {} },
'component',
'`component` and inline `data` are objectui-internal renderer capabilities',
);

orderPin(
'drill branch (#5022): the "AUTOMATIC" answer comes first',
{ drillDown: { enabled: true } },
'drillDown',
'Drill-through on a dashboard is AUTOMATIC and not configurable per widget',
);

it('keeps the whole drill answer ahead of the history, not just its opening', () => {
// The #5022 branch is the longest of the three; its two "where the real
// drills live" pointers are the actionable part and must not slip behind.
const m = messageFor({ drillDown: { enabled: true } });
expect(m.indexOf('`ChartDrillDownSchema`')).toBeLessThan(m.indexOf(HISTORY));
expect(m.indexOf('`ReportSchema.drilldown` (ADR-0021 D2, on by default).'))
.toBeLessThan(m.indexOf(HISTORY));
});

it('is unchanged in SHAPE when no branch matches — full-message pin', () => {
expect(messageFor({ colourVariant: 'blue' }))
.toBe(`Unrecognized key(s) on this dashboard widget: \`colourVariant\`. ${HISTORY}`);
});

it('emits the history exactly once, whatever the key count', () => {
const m = messageFor({ categoryField: 'stage', alsoWrong: 1, andThis: 2 });
expect(m.split(HISTORY)).toHaveLength(2);
expect(m.endsWith(` ${HISTORY}`)).toBe(true);
});
});

describe('DashboardSchema', () => {
it('parses a dataset-bound dashboard', () => {
const d = DashboardSchema.parse({
Expand Down
38 changes: 29 additions & 9 deletions packages/spec/src/ui/dashboard.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,29 +141,48 @@ const QUARANTINED_WIDGET_KEYS = new Set(['component', 'data']);
* objectui-internal prop it points the author at the ADR-0021 dataset shape
* (and `options` for renderer-specific extras). Mirrors `strictVisibilityError`
* (ADR-0089 D3a); every other issue code defers to zod's default.
*
* ## Message order: the fix comes before the history (#5955 / #6416)
*
* ```text
* Unrecognized key(s) on this dashboard widget: `k1`. ← which key is wrong
* [ one of the three prescription branches ] ← the fix
* Undeclared top-level keys were dropped silently … ← why it used to be silent
* ```
*
* Hand-written `$ZodErrorMap`s were out of reach of both #5955 (which moved the
* sentence inside the shared `strictUnknownKeyError`) and #5593 (which migrates
* the direct call sites to `strictObject`); #6416 applies the same ruling here.
* The history sentence used to sit between the key statement and the branch that
* fixes it, pushing every prescription — the ADR-0021 dataset migration, the
* objectui quarantine, the #5022 drill answer — past character ~220 of a message
* several consumers render on ONE line. Nothing is dropped or made conditional:
* the sentence is still emitted verbatim, just last.
*/
const strictWidgetAnalyticsError: z.core.$ZodErrorMap = (issue) => {
if (issue.code !== 'unrecognized_keys') return undefined;
const keys = (issue as { keys?: readonly string[] }).keys ?? [];
const list = keys.map((k) => `\`${k}\``).join(', ');
const base =
`Unrecognized key(s) on this dashboard widget: ${list}. ` +
const front = `Unrecognized key(s) on this dashboard widget: ${list}.`;
const history =
`Undeclared top-level keys were dropped silently before strict validation, ` +
`shipping inert metadata; a stale or mis-layered key is now a loud parse error.`;
if (keys.some((k) => LEGACY_WIDGET_ANALYTICS_KEYS.has(k))) {
return (
base +
front +
' The pre-ADR-0021 inline analytics shape (`object` + `categoryField` + ' +
'`valueField` + `aggregate`, pivot `rowField`/`columnField`) was removed — ' +
'bind a `dataset` and select `dimensions` + `values` by name. Renderer-only ' +
'settings belong under `options`.'
'settings belong under `options`. ' +
history
);
}
if (keys.some((k) => QUARANTINED_WIDGET_KEYS.has(k))) {
return (
base +
front +
' `component` and inline `data` are objectui-internal renderer capabilities, ' +
'not part of the author-facing dashboard spec (framework#3251).'
'not part of the author-facing dashboard spec (framework#3251). ' +
history
);
}
// #5022 — the drill near-key, in all three spellings an author reaches for.
Expand All @@ -175,18 +194,19 @@ const strictWidgetAnalyticsError: z.core.$ZodErrorMap = (issue) => {
// between two real keys on two other surfaces.
if (keys.some((k) => k === 'drillDown' || k === 'drilldown' || k === 'drill')) {
return (
base +
front +
' Drill-through on a dashboard is AUTOMATIC and not configurable per widget: ' +
'a dataset-bound widget derives the drill target and filter from the dataset row ' +
'that was clicked, and a `table`/`pivot` widget is the one to reach for when you ' +
'want the detail to be clickable (`metric`/`chart` render the aggregate only). ' +
'The two configurable drills live elsewhere and neither is a widget key: ' +
'`drillDown` (camelCase, a config object) is the react-tier `<ObjectChart drillDown={…}>` ' +
'prop — `ChartDrillDownSchema`; `drilldown` (all lowercase, a boolean) is ' +
'`ReportSchema.drilldown` (ADR-0021 D2, on by default).'
'`ReportSchema.drilldown` (ADR-0021 D2, on by default). ' +
history
);
}
return base;
return `${front} ${history}`;
};

/**
Expand Down
Loading
Loading