Skip to content

Commit de3dcf5

Browse files
committed
feat(types)!: retire the PartialSchema export, which had no reader; a census pin keeps it gone (objectui#11608)
The census at work time found no reader of `PartialSchema` in objectui, objectstack `main` or hotcrm (cloud not reachable from this account). The export and its docblock leave `packages/types/src/index.ts` with no replacement alias, and the #8347 pin drops its `PartialSchema` section and the two imports only that section read. `partial-schema-retired-11608.test.ts` pins the retirement on three instruments: a `@ts-expect-error` row on the root barrel (with a lit sibling control), a vitest read of the source module behind every `exports` entry, and a tracked-tree `git grep` with a lit control. It also compiles the changeset's TO spelling. Claude-Session: https://claude.ai/code/session_01CPvhwGcirXqBGEdPSb72TZ Co-authored-by: Claude <noreply@anthropic.com>
1 parent b403bb3 commit de3dcf5

4 files changed

Lines changed: 215 additions & 46 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@object-ui/types': minor
3+
---
4+
5+
**BREAKING — `PartialSchema<T>` is RETIRED from `@object-ui/types`** (objectui#11608, enforce-or-remove). The utility type leaves the `.` entry, the one entry that published it, with no replacement alias.
6+
7+
**Clause-②: yes (narrowing)**, shipped as `minor` per this repository's version policy: one name leaves the published surface, and the break is stated here.
8+
9+
- **Why.** The alias had no reader: no producer, doc or skill in this repository, and none in the sibling repositories the census could read. While `BaseSchema` carried an index signature it declared `type` alone, whatever `T` was (objectui#6397). objectui#8347 removed that signature, which made the alias work as written and brought its published-export question due. A published capability with no reader is retired, not kept for its sunk cost.
10+
- **objectui#8347's note.** That release note says `PartialSchema<T>` works as written once `BaseSchema` lost its index signature. This removal supersedes it.
11+
12+
**FROM** `import type { PartialSchema } from '@object-ui/types'`, annotating a value as `PartialSchema<T>`.
13+
**TO** the node type's own declared members: annotate a whole node with its node type (`ButtonSchema`, `InputSchema`, …). For a partial value, write `Partial<T> & { type: T['type'] }` inline. It keeps every member `T` declares, with `type` required and the rest optional, and a misspelled key is still refused.
14+
15+
```ts
16+
// before
17+
import type { ButtonSchema, PartialSchema } from '@object-ui/types';
18+
const patch: PartialSchema<ButtonSchema> = { type: 'button', label: 'Save' };
19+
20+
// after: the import above is a compile error naming the symbol
21+
import type { ButtonSchema } from '@object-ui/types';
22+
const patch: Partial<ButtonSchema> & { type: ButtonSchema['type'] } = { type: 'button', label: 'Save' };
23+
```

‎packages/types/src/__tests__/base-schema-closed-face-8347.test.ts‎

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,6 @@
3939
import { describe, it, expect } from 'vitest';
4040
import type { BaseSchema, ComponentRendererProps } from '../base';
4141
import type { CalendarViewSchema } from '../complex';
42-
import type { ButtonSchema } from '../form';
43-
import type { PartialSchema } from '../index';
4442

4543
type Equal<A, B> =
4644
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
@@ -97,24 +95,10 @@ export const nestedCorrect: BaseSchema = { type: 'card', children: [{ type: 'but
9795
const arrived = { type: 'calendar-view' as const, titleField: 'subject', titleFieldd: 'subject' };
9896
export const widenedIsNotRechecked: CalendarViewSchema = arrived;
9997

100-
/* ── 4. `PartialSchema` works as written now (objectui#6397) ─────────────── */
101-
102-
// While the signature stood, `keyof T` absorbed every member name and this alias
103-
// declared `type` alone. It keeps `T`'s named members now…
104-
export type _PartialKeepsTheMembers = Expect<Equal<keyof PartialSchema<ButtonSchema>, keyof ButtonSchema>>;
105-
// …with `type` required and the rest optional…
106-
export const partialButton: PartialSchema<ButtonSchema> = { type: 'button' };
107-
// …and it refuses a misspelling like every node type.
108-
export const partialMisspelled: PartialSchema<ButtonSchema> = {
109-
type: 'button',
110-
// @ts-expect-error — `labell` is no member of `ButtonSchema`; the key is `label`
111-
labell: 'Save',
112-
};
113-
11498
describe('`BaseSchema` declares no index signature (objectui#8347, the objectui#7927 pin)', () => {
11599
it('keeps the type-level rows alive — `tsc -p tsconfig.test.json` is their reader', () => {
116-
expect([p5Correct.titleField, baseCorrect.className, partialButton.type]).toEqual(['subject', 'font-bold', 'button']);
117-
expect([p5Misspelled, baseMisspelled, nestedMisspelled, nestedCorrect, widenedIsNotRechecked, partialMisspelled])
118-
.toHaveLength(6);
100+
expect([p5Correct.titleField, baseCorrect.className]).toEqual(['subject', 'font-bold']);
101+
expect([p5Misspelled, baseMisspelled, nestedMisspelled, nestedCorrect, widenedIsNotRechecked])
102+
.toHaveLength(5);
119103
});
120104
});
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* `PartialSchema` stays RETIRED from `@object-ui/types` (objectui#11608,
11+
* enforce-or-remove).
12+
*
13+
* ## The ruling
14+
*
15+
* The alias was published through the `.` entry and had no reader. While
16+
* `BaseSchema` carried `[key: string]: any` it did not even do what its
17+
* docblock said: every instantiation declared `type` alone (objectui#6397).
18+
* objectui#8347 removed that signature, which made the alias work for the
19+
* first time, and handed the published-export question to this card. The triage
20+
* direction was retire unless a census at work time found a reader; it found
21+
* none, so the export and its docblock went, with no replacement alias.
22+
* ⛔ Putting it back is a published-contract decision, never a convenience.
23+
*
24+
* ## ⚠️ The limit this pin inherits and does NOT close
25+
*
26+
* The tree scan reads this repository's TRACKED files only: ⛔ not sibling
27+
* repositories, ⛔ not customer applications, ⛔ not untracked files. The
28+
* census behind the ruling also read the sibling repositories it could reach,
29+
* and its pull request names the leg it could not. An external TypeScript
30+
* consumer gets a compile error naming the symbol; the changeset's migration is
31+
* what it reads.
32+
*
33+
* ## Three instruments, and why the obvious fourth is absent
34+
*
35+
* - `tsc -p tsconfig.test.json`, chained from this package's `type-check`
36+
* script, reads the `@ts-expect-error` row on the root barrel and the rows
37+
* that compile the changeset's TO spelling. vitest strips types, so those
38+
* rows mean nothing unless `type-check` runs.
39+
* - vitest reads `package.json`'s `exports` map and the SOURCE module behind
40+
* every entry, so an entry added later is covered the day it lands. ⛔ Not
41+
* `dist/`: the per-PR `test` job builds nothing before it runs, the
42+
* constraint `package-exports-manifest.test.ts` records for this package.
43+
* - vitest scans the whole TRACKED tree for the word, with a lit control on
44+
* the same probe. This is the half that sees a re-export chain: an entry
45+
* that forwards with `export *` names nothing itself, but whatever module
46+
* declares the alias does, and that module is tracked.
47+
* - ⛔ A runtime `name in module` leg is deliberately absent: the alias was
48+
* `export type`, so it was never in a runtime namespace, and that leg would
49+
* pass identically before and after this retirement.
50+
*/
51+
52+
import { describe, it, expect } from 'vitest';
53+
import { existsSync, readFileSync } from 'node:fs';
54+
import { execFileSync } from 'node:child_process';
55+
import { dirname, relative, resolve } from 'node:path';
56+
import { fileURLToPath } from 'node:url';
57+
import type { ButtonSchema } from '../form';
58+
59+
/** Rooted at THIS file, never at `process.cwd()`: the two differ per invocation. */
60+
const HERE = dirname(fileURLToPath(import.meta.url));
61+
const PACKAGE_ROOT = resolve(HERE, '../..');
62+
const REPO_ROOT = resolve(HERE, '../../../..');
63+
64+
type Equal<A, B> =
65+
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
66+
type Expect<T extends true> = T;
67+
68+
/* ── 1. The type face: the root barrel no longer exports the alias ────────── */
69+
70+
// @ts-expect-error objectui#11608 — `PartialSchema` is RETIRED from the published barrel, the `.` entry it shipped through. No replacement alias; the changeset carries the migration.
71+
export type _RetiredFromTheRootBarrel = import('../index').PartialSchema<ButtonSchema>;
72+
73+
// ⭐ LIT CONTROL for the directive above, through the same import form with no
74+
// directive: the sibling utility it was declared beside. An emptied or moved
75+
// barrel would satisfy the directive on its own; it cannot satisfy this row.
76+
export type _SiblingStillResolves = import('../index').SchemaByType<'button'>;
77+
78+
/* ── 2. The TO spelling the changeset hands a consumer, compiled ─────────── */
79+
80+
// The inline spelling keeps every declared member, `type` required and the
81+
// rest optional, which is what the alias promised. `Partial` maps over the
82+
// members it is given, so it has no `Omit`-over-`keyof` step to collapse.
83+
type ButtonPatch = Partial<ButtonSchema> & { type: ButtonSchema['type'] };
84+
85+
export type _ToKeepsTheMembers = Expect<Equal<keyof ButtonPatch, keyof ButtonSchema>>;
86+
export const patchButton: ButtonPatch = { type: 'button' };
87+
// @ts-expect-error — `type` stays required on the inline spelling
88+
export const patchWithoutType: ButtonPatch = { label: 'Save' };
89+
export const patchMisspelled: ButtonPatch = {
90+
type: 'button',
91+
// @ts-expect-error — `labell` is no member of `ButtonSchema`; the key is `label`
92+
labell: 'Save',
93+
};
94+
95+
describe('objectui#11608 — the migration off `PartialSchema` compiles (type-level rows)', () => {
96+
it('keeps the type-level rows alive — `tsc -p tsconfig.test.json` is their reader', () => {
97+
expect(patchButton.type).toBe('button');
98+
expect([patchWithoutType, patchMisspelled]).toHaveLength(2);
99+
});
100+
});
101+
102+
/* ── 3. The `exports` map: no entry's source module names the alias ──────── */
103+
104+
const WORD = /\bPartialSchema\b/;
105+
106+
type ExportsMap = Record<string, string | Record<string, string>>;
107+
108+
/** Every entry of the `exports` map, with the `src/` module its `types` target is emitted from. */
109+
const entrySources = (): Array<{ entry: string; source: string }> => {
110+
const pkg = JSON.parse(readFileSync(resolve(PACKAGE_ROOT, 'package.json'), 'utf8')) as { exports?: ExportsMap };
111+
return Object.entries(pkg.exports ?? {}).map(([entry, conditions]) => {
112+
const target = typeof conditions === 'string' ? conditions : conditions.types;
113+
// `./dist/X.d.ts` is what `tsc` emits from `src/X.ts`, under this
114+
// package's `rootDir: ./src` and `outDir: ./dist`.
115+
const match = /^\.\/dist\/(.+)\.d\.ts$/.exec(target ?? '');
116+
expect(match, `entry ${entry} has no ./dist/*.d.ts types target`).not.toBeNull();
117+
return { entry, source: resolve(PACKAGE_ROOT, 'src', `${match![1]}.ts`) };
118+
});
119+
};
120+
121+
describe('objectui#11608 — `PartialSchema` is published from no entry of the `exports` map', () => {
122+
it('every entry resolves to a source module this test can read', () => {
123+
const entries = entrySources();
124+
// The `.` entry is the one the alias shipped through: an enumeration
125+
// without it would make the next assertion vacuous where it matters most.
126+
expect(entries.map((e) => e.entry)).toContain('.');
127+
expect(entries.filter((e) => !existsSync(e.source)).map((e) => e.entry)).toEqual([]);
128+
});
129+
130+
it('no entry source module names `PartialSchema`', () => {
131+
const naming = entrySources()
132+
.filter((e) => WORD.test(readFileSync(e.source, 'utf8')))
133+
.map((e) => `${e.entry} -> ${relative(REPO_ROOT, e.source)}`);
134+
expect(naming).toEqual([]);
135+
});
136+
137+
it('LIT CONTROL — the same read finds `SchemaByType` in the `.` entry source', () => {
138+
const root = entrySources().find((e) => e.entry === '.');
139+
expect(/\bexport type SchemaByType\b/.test(readFileSync(root!.source, 'utf8'))).toBe(true);
140+
});
141+
});
142+
143+
/* ── 4. The tracked tree: nothing names it ────────────────────────────────── */
144+
145+
describe('objectui#11608 — no tracked file outside the release record names `PartialSchema`', () => {
146+
/**
147+
* What is excluded, and why each row is here. ⛔ No allow-list FILE: a list
148+
* that lives on disk outlives the reason for each of its rows.
149+
*
150+
* - `*CHANGELOG.md` — released notes, which must keep naming what shipped
151+
* and what was later removed.
152+
* - `.changeset/` — pending notes, the same record before a release folds
153+
* it into a CHANGELOG. This retirement's own note has to name the alias.
154+
* - this pin, which must write the word to probe for it.
155+
*/
156+
const EXCLUDED = [
157+
':!*CHANGELOG.md',
158+
':!.changeset/',
159+
':!packages/types/src/__tests__/partial-schema-retired-11608.test.ts',
160+
];
161+
162+
/** `git grep -nE PATTERN -- . EXCLUSIONS`, exit 1 (no match) normalised to an empty list. */
163+
const grepTree = (pattern: string): string[] => {
164+
try {
165+
const out = execFileSync('git', ['grep', '-nE', pattern, '--', '.', ...EXCLUDED], {
166+
cwd: REPO_ROOT,
167+
encoding: 'utf8',
168+
});
169+
return out.split('\n').filter(Boolean);
170+
} catch (e) {
171+
// `git grep` exits 1 for "no matches", the PASS case here, told apart
172+
// from a real failure (exit > 1) rather than swallowed.
173+
const status = (e as { status?: number }).status;
174+
if (status === 1) return [];
175+
throw e;
176+
}
177+
};
178+
179+
it('the symbol `PartialSchema` appears nowhere: no declaration, no re-export, no reader, no doc', () => {
180+
expect(grepTree('\\bPartialSchema\\b')).toEqual([]);
181+
});
182+
183+
it('LIT CONTROL — the same probe finds `SchemaByType`, the sibling utility that stays', () => {
184+
// Without this, the zero above would also come from a broken `git grep`
185+
// invocation, a wrong cwd, or an exclusion list that swallowed the tree,
186+
// and a swallowed tree reads exactly like a clean retirement.
187+
expect(grepTree('\\bSchemaByType\\b').length).toBeGreaterThan(0);
188+
});
189+
});

‎packages/types/src/index.ts‎

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,33 +1075,6 @@ export type AnySchema =
10751075
*/
10761076
export type SchemaByType<T extends string> = Extract<AnySchema, { type: T }>;
10771077

1078-
/**
1079-
* Utility type to make all properties optional except the type.
1080-
* Useful for partial schema definitions in editors.
1081-
*
1082-
* ⚠️ It did NOT deliver that until objectui#8347 (objectui#6397 measured it):
1083-
* `Omit<T, K>` is `Pick<T, Exclude<keyof T, K>>`, and `keyof T` on a type
1084-
* carrying a string index signature is `string | number`, so while every
1085-
* `T extends BaseSchema` inherited `BaseSchema`'s `[key: string]: any`, the
1086-
* literal member names were absorbed and every instantiation declared exactly
1087-
* one property, `type`, beside a live index signature. objectui#8347 removed
1088-
* that signature, so `keyof T` is the literal member union again and the
1089-
* alias works as written, with no edit here. The collapse pin that recorded
1090-
* the old reading (`partial-schema-collapse-pin.test.ts`) asked to be deleted
1091-
* on that day, and was; `__tests__/base-schema-closed-face-8347.test.ts` pins
1092-
* the working reading.
1093-
*
1094-
* ⚠️ A `T` that declares its OWN index signature still collapses the same way;
1095-
* reach for `OmitDeclared` (`./form.ts`) there, the key-remapping spelling
1096-
* objectui#9256 used. It had no in-repo consumer at the removal (a reading
1097-
* taken then, which nothing re-derives); whether to keep or retire it is a
1098-
* published-export question for the maintainer (the pin's own removal
1099-
* condition asked for that card), ⛔ not something to settle here.
1100-
*/
1101-
export type PartialSchema<T extends BaseSchema> = {
1102-
type: T['type'];
1103-
} & Partial<Omit<T, 'type'>>;
1104-
11051078
/**
11061079
* Schema with required children (for container components).
11071080
*/

0 commit comments

Comments
 (0)