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
23 changes: 23 additions & 0 deletions .changeset/11608-partialschema-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@object-ui/types': minor
---

**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.

**Clause-②: yes (narrowing)**, shipped as `minor` per this repository's version policy: one name leaves the published surface, and the break is stated here.

- **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.
- **objectui#8347's note.** That release note says `PartialSchema<T>` works as written once `BaseSchema` lost its index signature. This removal supersedes it.

**FROM** `import type { PartialSchema } from '@object-ui/types'`, annotating a value as `PartialSchema<T>`.
**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.

```ts
// before
import type { ButtonSchema, PartialSchema } from '@object-ui/types';
const patch: PartialSchema<ButtonSchema> = { type: 'button', label: 'Save' };

// after: the import above is a compile error naming the symbol
import type { ButtonSchema } from '@object-ui/types';
const patch: Partial<ButtonSchema> & { type: ButtonSchema['type'] } = { type: 'button', label: 'Save' };
```
22 changes: 3 additions & 19 deletions packages/types/src/__tests__/base-schema-closed-face-8347.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,6 @@
import { describe, it, expect } from 'vitest';
import type { BaseSchema, ComponentRendererProps } from '../base';
import type { CalendarViewSchema } from '../complex';
import type { ButtonSchema } from '../form';
import type { PartialSchema } from '../index';

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

/* ── 4. `PartialSchema` works as written now (objectui#6397) ─────────────── */

// While the signature stood, `keyof T` absorbed every member name and this alias
// declared `type` alone. It keeps `T`'s named members now…
export type _PartialKeepsTheMembers = Expect<Equal<keyof PartialSchema<ButtonSchema>, keyof ButtonSchema>>;
// …with `type` required and the rest optional…
export const partialButton: PartialSchema<ButtonSchema> = { type: 'button' };
// …and it refuses a misspelling like every node type.
export const partialMisspelled: PartialSchema<ButtonSchema> = {
type: 'button',
// @ts-expect-error — `labell` is no member of `ButtonSchema`; the key is `label`
labell: 'Save',
};

describe('`BaseSchema` declares no index signature (objectui#8347, the objectui#7927 pin)', () => {
it('keeps the type-level rows alive — `tsc -p tsconfig.test.json` is their reader', () => {
expect([p5Correct.titleField, baseCorrect.className, partialButton.type]).toEqual(['subject', 'font-bold', 'button']);
expect([p5Misspelled, baseMisspelled, nestedMisspelled, nestedCorrect, widenedIsNotRechecked, partialMisspelled])
.toHaveLength(6);
expect([p5Correct.titleField, baseCorrect.className]).toEqual(['subject', 'font-bold']);
expect([p5Misspelled, baseMisspelled, nestedMisspelled, nestedCorrect, widenedIsNotRechecked])
.toHaveLength(5);
});
});
189 changes: 189 additions & 0 deletions packages/types/src/__tests__/partial-schema-retired-11608.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* `PartialSchema` stays RETIRED from `@object-ui/types` (objectui#11608,
* enforce-or-remove).
*
* ## The ruling
*
* The alias was published through the `.` entry and had no reader. While
* `BaseSchema` carried `[key: string]: any` it did not even do what its
* docblock said: every instantiation declared `type` alone (objectui#6397).
* objectui#8347 removed that signature, which made the alias work for the
* first time, and handed the published-export question to this card. The triage
* direction was retire unless a census at work time found a reader; it found
* none, so the export and its docblock went, with no replacement alias.
* ⛔ Putting it back is a published-contract decision, never a convenience.
*
* ## ⚠️ The limit this pin inherits and does NOT close
*
* The tree scan reads this repository's TRACKED files only: ⛔ not sibling
* repositories, ⛔ not customer applications, ⛔ not untracked files. The
* census behind the ruling also read the sibling repositories it could reach,
* and its pull request names the leg it could not. An external TypeScript
* consumer gets a compile error naming the symbol; the changeset's migration is
* what it reads.
*
* ## Three instruments, and why the obvious fourth is absent
*
* - `tsc -p tsconfig.test.json`, chained from this package's `type-check`
* script, reads the `@ts-expect-error` row on the root barrel and the rows
* that compile the changeset's TO spelling. vitest strips types, so those
* rows mean nothing unless `type-check` runs.
* - vitest reads `package.json`'s `exports` map and the SOURCE module behind
* every entry, so an entry added later is covered the day it lands. ⛔ Not
* `dist/`: the per-PR `test` job builds nothing before it runs, the
* constraint `package-exports-manifest.test.ts` records for this package.
* - vitest scans the whole TRACKED tree for the word, with a lit control on
* the same probe. This is the half that sees a re-export chain: an entry
* that forwards with `export *` names nothing itself, but whatever module
* declares the alias does, and that module is tracked.
* - ⛔ A runtime `name in module` leg is deliberately absent: the alias was
* `export type`, so it was never in a runtime namespace, and that leg would
* pass identically before and after this retirement.
*/

import { describe, it, expect } from 'vitest';
import { existsSync, readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { ButtonSchema } from '../form';

/** Rooted at THIS file, never at `process.cwd()`: the two differ per invocation. */
const HERE = dirname(fileURLToPath(import.meta.url));
const PACKAGE_ROOT = resolve(HERE, '../..');
const REPO_ROOT = resolve(HERE, '../../../..');

type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

/* ── 1. The type face: the root barrel no longer exports the alias ────────── */

// @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.
export type _RetiredFromTheRootBarrel = import('../index').PartialSchema<ButtonSchema>;

// ⭐ LIT CONTROL for the directive above, through the same import form with no
// directive: the sibling utility it was declared beside. An emptied or moved
// barrel would satisfy the directive on its own; it cannot satisfy this row.
export type _SiblingStillResolves = import('../index').SchemaByType<'button'>;

/* ── 2. The TO spelling the changeset hands a consumer, compiled ─────────── */

// The inline spelling keeps every declared member, `type` required and the
// rest optional, which is what the alias promised. `Partial` maps over the
// members it is given, so it has no `Omit`-over-`keyof` step to collapse.
type ButtonPatch = Partial<ButtonSchema> & { type: ButtonSchema['type'] };

export type _ToKeepsTheMembers = Expect<Equal<keyof ButtonPatch, keyof ButtonSchema>>;
export const patchButton: ButtonPatch = { type: 'button' };
// @ts-expect-error — `type` stays required on the inline spelling
export const patchWithoutType: ButtonPatch = { label: 'Save' };
export const patchMisspelled: ButtonPatch = {
type: 'button',
// @ts-expect-error — `labell` is no member of `ButtonSchema`; the key is `label`
labell: 'Save',
};

describe('objectui#11608 — the migration off `PartialSchema` compiles (type-level rows)', () => {
it('keeps the type-level rows alive — `tsc -p tsconfig.test.json` is their reader', () => {
expect(patchButton.type).toBe('button');
expect([patchWithoutType, patchMisspelled]).toHaveLength(2);
});
});

/* ── 3. The `exports` map: no entry's source module names the alias ──────── */

const WORD = /\bPartialSchema\b/;

type ExportsMap = Record<string, string | Record<string, string>>;

/** Every entry of the `exports` map, with the `src/` module its `types` target is emitted from. */
const entrySources = (): Array<{ entry: string; source: string }> => {
const pkg = JSON.parse(readFileSync(resolve(PACKAGE_ROOT, 'package.json'), 'utf8')) as { exports?: ExportsMap };
return Object.entries(pkg.exports ?? {}).map(([entry, conditions]) => {
const target = typeof conditions === 'string' ? conditions : conditions.types;
// `./dist/X.d.ts` is what `tsc` emits from `src/X.ts`, under this
// package's `rootDir: ./src` and `outDir: ./dist`.
const match = /^\.\/dist\/(.+)\.d\.ts$/.exec(target ?? '');
expect(match, `entry ${entry} has no ./dist/*.d.ts types target`).not.toBeNull();
return { entry, source: resolve(PACKAGE_ROOT, 'src', `${match![1]}.ts`) };
});
};

describe('objectui#11608 — `PartialSchema` is published from no entry of the `exports` map', () => {
it('every entry resolves to a source module this test can read', () => {
const entries = entrySources();
// The `.` entry is the one the alias shipped through: an enumeration
// without it would make the next assertion vacuous where it matters most.
expect(entries.map((e) => e.entry)).toContain('.');
expect(entries.filter((e) => !existsSync(e.source)).map((e) => e.entry)).toEqual([]);
});

it('no entry source module names `PartialSchema`', () => {
const naming = entrySources()
.filter((e) => WORD.test(readFileSync(e.source, 'utf8')))
.map((e) => `${e.entry} -> ${relative(REPO_ROOT, e.source)}`);
expect(naming).toEqual([]);
});

it('LIT CONTROL — the same read finds `SchemaByType` in the `.` entry source', () => {
const root = entrySources().find((e) => e.entry === '.');
expect(/\bexport type SchemaByType\b/.test(readFileSync(root!.source, 'utf8'))).toBe(true);
});
});

/* ── 4. The tracked tree: nothing names it ────────────────────────────────── */

describe('objectui#11608 — no tracked file outside the release record names `PartialSchema`', () => {
/**
* What is excluded, and why each row is here. ⛔ No allow-list FILE: a list
* that lives on disk outlives the reason for each of its rows.
*
* - `*CHANGELOG.md` — released notes, which must keep naming what shipped
* and what was later removed.
* - `.changeset/` — pending notes, the same record before a release folds
* it into a CHANGELOG. This retirement's own note has to name the alias.
* - this pin, which must write the word to probe for it.
*/
const EXCLUDED = [
':!*CHANGELOG.md',
':!.changeset/',
':!packages/types/src/__tests__/partial-schema-retired-11608.test.ts',
];

/** `git grep -nE PATTERN -- . EXCLUSIONS`, exit 1 (no match) normalised to an empty list. */
const grepTree = (pattern: string): string[] => {
try {
const out = execFileSync('git', ['grep', '-nE', pattern, '--', '.', ...EXCLUDED], {
cwd: REPO_ROOT,
encoding: 'utf8',
});
return out.split('\n').filter(Boolean);
} catch (e) {
// `git grep` exits 1 for "no matches", the PASS case here, told apart
// from a real failure (exit > 1) rather than swallowed.
const status = (e as { status?: number }).status;
if (status === 1) return [];
throw e;
}
};

it('the symbol `PartialSchema` appears nowhere: no declaration, no re-export, no reader, no doc', () => {
expect(grepTree('\\bPartialSchema\\b')).toEqual([]);
});

it('LIT CONTROL — the same probe finds `SchemaByType`, the sibling utility that stays', () => {
// Without this, the zero above would also come from a broken `git grep`
// invocation, a wrong cwd, or an exclusion list that swallowed the tree,
// and a swallowed tree reads exactly like a clean retirement.
expect(grepTree('\\bSchemaByType\\b').length).toBeGreaterThan(0);
});
});
27 changes: 0 additions & 27 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1080,33 +1080,6 @@ export type AnySchema =
*/
export type SchemaByType<T extends string> = Extract<AnySchema, { type: T }>;

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

/**
* Schema with required children (for container components).
*/
Expand Down
Loading