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
13 changes: 13 additions & 0 deletions .changeset/default-value-literal-gate-prefers-rename.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/spec": patch
---

The authoring-time `defaultValue` gate now prescribes the key rename an author actually made, instead of a type error about a member they never wrote.

`checkLiteralDefaultValue` — the shared core of the field gate (`FieldSchema.defaultValue`) and the action-param gate (`ActionParamSchema.defaultValue`) — read a value-contract rejection positionally, `result.error.issues[0]`. zod reports per-member issues before the object-level `unrecognized_keys` one, so on a default whose keys were **renamed** the actionable message sorted last and was discarded. An `address` default authored as `{ street: 5, postal_code: '98101' }` answered `Invalid input: expected string, received number`, and a `location` default authored as the legacy `{ latitude, longitude }` pair answered `Invalid input: expected number, received undefined` — while `AddressValueSchema` and `LocationValueSchema` had each built the rename prescription and thrown it away. Which of the two the author got depended on whether some unrelated member happened to also be wrong: nobody chose that, and nobody could see it.

The gate now prefers the undeclared-key issue when the rejection carries one. `LiteralDefaultValueVerdict.detail` keeps its name, its type and its documented meaning — "the 'why' a refusal carries verbatim"; what changes is which of several already-reachable messages it carries.

⛔ No verdict moves. Exactly the same defaults are accepted and refused, on the same evidence — only the refusal text changes.

Scoped by measurement rather than inherited: the sixteen classes `valueSchemaFor(def, 'stored')` covers were swept again on this function, at both arities. Only `location` and `address` can emit `unrecognized_keys` at all, because only they are backed by a key-closed object schema — for the other fourteen the preference cannot change a single character. Both classes it does reach curate the alias map that makes the undeclared key the more actionable half of the rejection.
56 changes: 56 additions & 0 deletions packages/spec/src/data/default-value-shape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,60 @@ describe('#7127 checkLiteralDefaultValue — the shared stored-form literal chec
it('stays open where the contract is deliberately open (json)', () => {
expect(checkLiteralDefaultValue({ type: 'json' }, { anything: ['at', 'all'] }).ok).toBe(true);
});

// ── #16077: `detail` is the ACTIONABLE issue, not `issues[0]` ─────────────
//
// zod sorts per-member issues ahead of the object-level `unrecognized_keys`
// one, so a positional read handed an author who RENAMED a key a
// missing-member type error about a member they never wrote — and which of
// the two they got depended on whether some unrelated member happened to
// also be wrong. Each case below asserts BOTH halves: the prescription is
// present, AND the half that was being shown instead is gone. Without the
// second the pin cannot see a regression back to the positional read.
it('#16077 prefers the rename over a MISSING-member type error (location)', () => {
const v = checkLiteralDefaultValue({ type: 'location' }, { latitude: 1, longitude: 2 });
expect(v.ok).toBe(false);
// Positionally this rejection reads
// `[invalid_type(lat), invalid_type(lng), unrecognized_keys]`.
expect(v.detail).toContain('`latitude` \u2192 `lat`');
expect(v.detail).toContain('`longitude` \u2192 `lng`');
expect(v.detail).not.toContain('expected number, received undefined');
// Edit distance cannot reach `latitude` -> `lat`; the curated `aliases`
// map is the only thing that can, which is why discarding it cost the
// author the whole prescription.
});

it('#16077 prefers the rename over a WRONG-TYPED-member error (address)', () => {
const v = checkLiteralDefaultValue({ type: 'address' }, { street: 5, postal_code: '98101' });
expect(v.ok).toBe(false);
// Every member of `address` is optional, which rules out a MISSING-member
// error but says nothing about a wrong-typed declared one — it sorts ahead
// just the same. This is the case that made the defect look location-only.
expect(v.detail).toContain('`postal_code` \u2192 `postalCode`');
expect(v.detail).not.toContain('expected string, received number');
});

it('#16077 leaves the already-correct case exactly as it was (the asymmetry is gone)', () => {
// No member error to sort ahead, so this one was always right. Pinning it
// beside the two above is what states the property: the diagnosis no
// longer depends on whether an unrelated member happened to also be wrong.
const lucky = checkLiteralDefaultValue({ type: 'address' }, { postal_code: '98101' });
const unlucky = checkLiteralDefaultValue({ type: 'address' }, { street: 5, postal_code: '98101' });
expect(lucky.ok).toBe(false);
expect(lucky.detail).toContain('`postal_code` \u2192 `postalCode`');
expect(unlucky.detail).toContain('`postal_code` \u2192 `postalCode`');
});

it('#16077 is a NO-OP for a class that cannot emit `unrecognized_keys`', () => {
// The sweep over all sixteen classes `valueSchemaFor(def, 'stored')`
// covers found only `location` and `address` backed by a `strictObject`,
// so only they can emit the issue the preference looks for. For the other
// fourteen the selected message is `issues[0]` exactly as before — this
// pin is the one that goes red if the preference ever starts reordering
// a class it has no business reordering.
const v = checkLiteralDefaultValue({ type: 'datetime' }, '2026-08-10T15:00');
expect(v.ok).toBe(false);
expect(v.detail).toContain('ISO-8601 instant');
expect(v.detail).not.toContain('Unrecognized key');
});
});
52 changes: 49 additions & 3 deletions packages/spec/src/data/default-value-shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,59 @@ export function discriminateDefaultValueShape(dv: unknown): DefaultValueShape {
return 'literal';
}

/** Verdict of {@link checkLiteralDefaultValue}: `ok`, or the first contract violation. */
/** Verdict of {@link checkLiteralDefaultValue}: `ok`, or the contract violation to act on. */
export interface LiteralDefaultValueVerdict {
ok: boolean;
/** First issue message from the value contract — the "why" a refusal carries verbatim. */
/**
* The ACTIONABLE issue message from the value contract — the "why" a refusal
* carries verbatim. Selected by {@link actionableValueIssueMessage}, not read
* positionally; the field's name, type and meaning are what they always were.
*/
detail?: string;
}

/**
* The one parse issue an author can act on, out of everything zod reported for
* this literal's value-shape rejection.
*
* NOT `issues[0]`. zod reports per-member issues before the object-level
* `unrecognized_keys` one, so on a default whose keys were RENAMED the
* actionable message sorts LAST and a positional read discards it. An
* `address` default authored as `{ street: 5, postal_code: '98101' }` reports:
*
* [0] invalid_type street Invalid input: expected string, received number
* [1] unrecognized_keys ... Did you mean `postal_code` -> `postalCode`? ...
*
* The rename IS the prescription, and edit distance cannot reach it
* (`latitude` -> `lat`), which is exactly why `LocationValueSchema` and
* `AddressValueSchema` curate an `aliases` map. Reading positionally built that
* hint and threw it away, handing the author a missing-member type error about
* a member they never wrote — and which of the two they got depended on whether
* some unrelated member happened to also be wrong, which nobody chose and
* nobody can see.
*
* The preference is a NO-OP for every other class rather than merely harmless
* to it, which is what makes it safe as a blanket rule. Swept on THIS function
* over all sixteen classes `valueSchemaFor(def, 'stored')` covers, at both
* arities: only `location` and `address` can emit `unrecognized_keys` at all,
* because only they are backed by a `strictObject`. The string, numeric,
* boolean, calendar-date, instant, clock-time, option, reference and
* file-reference classes are scalars; `composite` / `record` / `repeater` /
* `vector` are open records and arrays; the open fallback is `z.unknown()`. For
* the other fourteen this cannot change a single character.
*
* The stored-value scan reached the same reading from the other side
* (objectql `record-validator.ts`'s `valueShapeDetail`). Two readings of one
* rejection, one per surface — deliberately not shared, because `packages/spec`
* is upstream of `objectql` and this gate answers a metadata AUTHOR while that
* one answers an operator running a migration.
*/
function actionableValueIssueMessage(
issues: ReadonlyArray<{ code: string; message: string }>,
): string {
return (issues.find((i) => i.code === 'unrecognized_keys') ?? issues[0])?.message ?? 'invalid value';
}

/**
* Check a LITERAL default against its owner's own stored-form value contract
* (`valueSchemaFor(def, 'stored')` — ADR-0104 D1). The shared core of the
Expand All @@ -117,7 +163,7 @@ export interface LiteralDefaultValueVerdict {
export function checkLiteralDefaultValue(def: ValueShapeFieldDef, dv: unknown): LiteralDefaultValueVerdict {
const result = valueSchemaFor(def, 'stored').safeParse(dv);
if (result.success) return { ok: true };
return { ok: false, detail: result.error.issues[0]?.message ?? 'invalid value' };
return { ok: false, detail: actionableValueIssueMessage(result.error.issues) };
}

/* ────────────────────────────────────────────────────────────────────────────
Expand Down
30 changes: 29 additions & 1 deletion packages/spec/src/data/field-default-value.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ type Case = {
accepted: boolean;
/** Substrings the refusal message must carry (rejection rows only). */
contains?: string[];
/**
* Substrings the refusal message must NOT carry — the wrong half of a
* rejection that a positional issue read would have selected (#16077).
*/
notContains?: string[];
};

const CASES: Case[] = [
Expand Down Expand Up @@ -86,6 +91,26 @@ const CASES: Case[] = [
accepted: false,
contains: ['`heading`'],
},
// #16077 — the two rows above are the LUCKY half: their other members are
// well-typed, so nothing sorted ahead of the object-level issue and the
// rename surfaced by accident. These two are the unlucky half, where a
// positional `issues[0]` read handed the author a type error about a member
// they never wrote. `notContains` names the half that was shown instead —
// without it the row cannot see a regression back to the positional read.
{
label: '#16077 location + the RENAMED legacy pair (a missing-member error sorts ahead)',
field: { type: 'location', defaultValue: { latitude: 37.77, longitude: -122.42 } },
accepted: false,
contains: ['`latitude` \u2192 `lat`', '`longitude` \u2192 `lng`'],
notContains: ['expected number, received undefined'],
},
{
label: '#16077 address + a renamed key beside a WRONG-TYPED declared one',
field: { type: 'address', defaultValue: { street: 5, postal_code: '98101' } },
accepted: false,
contains: ['`postal_code` \u2192 `postalCode`'],
notContains: ['expected string, received number'],
},

// ── Literal branch: valid literals stay accepted ──────────────────────────
{ label: 'VALID number', field: { type: 'number', defaultValue: 7 }, accepted: true },
Expand Down Expand Up @@ -227,7 +252,7 @@ const CASES: Case[] = [
];

describe('#7127 FieldSchema.defaultValue — three shapes, each judged on its own terms', () => {
for (const { label, field, accepted, contains } of CASES) {
for (const { label, field, accepted, contains, notContains } of CASES) {
it(`${accepted ? 'accepts' : 'rejects'}: ${label}`, () => {
const issue = defaultValueIssue(field);
if (accepted) {
Expand All @@ -243,6 +268,9 @@ describe('#7127 FieldSchema.defaultValue — three shapes, each judged on its ow
for (const fragment of contains ?? []) {
expect(issue!.message).toContain(fragment);
}
for (const fragment of notContains ?? []) {
expect(issue!.message).not.toContain(fragment);
}
});
}

Expand Down
20 changes: 20 additions & 0 deletions packages/spec/src/ui/action-param-default-value.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,26 @@ describe('#6970 ActionParamSchema.defaultValue — authored defaults meet the pa
});
}

it('#16077 carries the rename, not a member type error, on a renamed structured default', () => {
// The action-param gate and the field gate share ONE core
// (`checkLiteralDefaultValue`), so the positional issue read cost this
// surface the same prescription. Pinned here as well as at the core
// because this consumer composes `verdict.detail` into its own message:
// a core fix that never reached the composed text would be invisible to a
// core-only pin.
const issue = defaultValueIssue({
name: 'site',
type: 'location',
defaultValue: { latitude: 37.77, longitude: -122.42 },
})!;
expect(issue).not.toBeNull();
expect(issue.path).toEqual(['defaultValue']);
expect(issue.message).toContain('`latitude` \u2192 `lat`');
expect(issue.message).toContain('`longitude` \u2192 `lng`');
// The half a positional `issues[0]` read selected instead.
expect(issue.message).not.toContain('expected number, received undefined');
});

it("names the author's default as the cause, not just the param", () => {
const issue = defaultValueIssue({ name: 'start', type: 'datetime', defaultValue: '2026-08-10T15:00' })!;
// The underlying reason is carried verbatim from the shared value contract,
Expand Down
Loading