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
27 changes: 27 additions & 0 deletions .changeset/9412-ungated-docs-paydown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
---

Pay down the three `UNGATED_DOCS` rows objectui#7308 opened for the nested
package READMEs, so `scripts/check-doc-snippet-types.mjs` compiles all three
pages instead of declaring them unverified.

The one reader-visible defect is on `packages/core/src/adapters/README.md`: the
custom-adapter template declared `implements DataSource<T>` while omitting
`getObjectSchema`, which the interface requires, and wrote `// Your
implementation` as the whole body of six methods annotated non-`void`. A reader
who copied it got a class that does not satisfy the interface it claims. The
template now carries all six required members and throws from each
unimplemented body, so it type-checks at every step of being filled in.

`packages/types/src/zod/README.md` had two `{ ... }` elisions TypeScript reads
as a spread with no operand, six excerpts continuing an earlier block's imports,
one fence holding a before-and-after pair that declared the same two names
twice, and a shape sketch fenced as TypeScript. `packages/components/src/__tests__/README.md`
gains the imports a file in that directory really writes, and a fragment
declaration saying why a probe compiled at the repository root cannot resolve
either of them.

No published file changes: none of the three pages is inside any package
tarball — each package's manifest `files` list carries `dist` and the
package-root `README.md`, and `npm pack --dry-run` reports zero entries under
`src/` for all three. Gate script and its own test suite only, otherwise.
12 changes: 11 additions & 1 deletion packages/components/src/__tests__/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,19 @@ The failing tests have discovered legitimate issues with component schemas and p

## Adding New Tests

When adding a new component, follow this pattern:
When adding a new component, follow this pattern — the imports are the ones a
file in this directory writes, so copy the block whole:

<!-- doc-snippet: fragment — this pattern is a test file for THIS suite, so it imports `vitest` — which only the repository root declares, and which `scripts/check-doc-snippet-types.mjs` refuses for that reason — and the sibling `./test-utils`, a suite-local module `@object-ui/components` does not ship (its `files` list is `dist` only, and `dist/` holds no `test-utils`). Both specifiers are correct where the reader writes them and neither resolves in a probe compiled at the repository root -->
```typescript
import { describe, it, expect } from 'vitest';

import {
getAllDisplayIssues,
renderComponent,
validateComponentRegistration,
} from './test-utils';

describe('NewComponent Renderer', () => {
it('should be properly registered', () => {
const validation = validateComponentRegistration('new-component');
Expand Down
83 changes: 66 additions & 17 deletions packages/core/src/adapters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,11 @@ renderer calls; components do not branch on `provider` themselves.

```typescript
import { resolveDataSource } from '@object-ui/core';
import type { DataSource } from '@object-ui/types';

// The `DataSource` the renderer already holds from context. It is what
// `provider: 'object'` resolves to, and the fallback for every other case.
declare const contextDataSource: DataSource;

const dataSource = resolveDataSource(
{ provider: 'api', read: { url: '/api/users' } },
Expand All @@ -215,6 +220,10 @@ stay ignorant of which one ran.

```typescript
import { runBatchTransaction } from '@object-ui/core';
import type { DataSource } from '@object-ui/types';

// The adapter the view resolved to — see `resolveDataSource` above.
declare const dataSource: DataSource;

// `{ $ref: 0 }` resolves to the id minted by operation 0 (the parent).
await runBatchTransaction(dataSource, [
Expand All @@ -234,39 +243,79 @@ for the capability negotiation that decides which path is taken.

## Creating Custom Adapters

To create a custom adapter, implement the `DataSource<T>` interface:
To create a custom adapter, implement the `DataSource<T>` interface. It requires
**six** members — `find`, `findOne`, `create`, `update`, `delete` and
`getObjectSchema` — and everything else on it is optional. `getObjectSchema` is
easy to miss and is not optional: schema-dependent components call it before they
render, which is why `ApiDataSource` answers it with a minimal stub rather than
omitting it.

```typescript
import type { DataSource, QueryParams, QueryResult } from '@object-ui/types';

export class MyCustomAdapter<T = any> implements DataSource<T> {
// ── The six members `DataSource<T>` requires ───────────────────────────────

async find(resource: string, params?: QueryParams): Promise<QueryResult<T>> {
// Your implementation
throw new Error(`find(${resource}) is not implemented yet`);
}

async findOne(resource: string, id: string | number): Promise<T | null> {
// Your implementation

async findOne(
resource: string,
id: string | number,
params?: QueryParams,
): Promise<T | null> {
throw new Error(`findOne(${resource}, ${id}) is not implemented yet`);
}

async create(resource: string, data: Partial<T>): Promise<T> {
// Your implementation
throw new Error(`create(${resource}) is not implemented yet`);
}

async update(
resource: string,
id: string,
data: Partial<T>,
opts?: { ifMatch?: string },
): Promise<T> {
throw new Error(`update(${resource}, ${id}) is not implemented yet`);
}

async update(resource: string, id: string | number, data: Partial<T>): Promise<T> {
// Your implementation

async delete(
resource: string,
id: string | number,
opts?: { ifMatch?: string },
): Promise<boolean> {
throw new Error(`delete(${resource}, ${id}) is not implemented yet`);
}

async delete(resource: string, id: string | number): Promise<boolean> {
// Your implementation

/**
* Required. Return the object's metadata, or a minimal stub
* (`{ name, fields: {} }`) when your backend exposes none — see
* `ApiDataSource` above.
*/
async getObjectSchema(objectName: string): Promise<any> {
return { name: objectName, fields: {} };
}

// Optional: bulk operations
async bulk?(resource: string, operation: string, data: Partial<T>[]): Promise<T[]> {
// Your implementation

// ── Optional: implement only what your backend actually supports ───────────

async bulk?(
resource: string,
operation: 'create' | 'update' | 'delete',
data: Partial<T>[],
): Promise<T[]> {
throw new Error(`bulk(${resource}, ${operation}) is not implemented yet`);
}
}
```

The bodies above **throw** rather than fall off the end: a method annotated
`Promise<QueryResult<T>>` that returns nothing is a type error, and a template
that does not type-check is one a reader copies into a class that does not
satisfy the interface it claims to implement. Replace each `throw` as you go and
the class stays checkable at every step.

## Related Packages

- `@object-ui/types` — the `DataSource`, `QueryParams` and `ViewData` definitions these adapters implement
Expand Down
47 changes: 38 additions & 9 deletions packages/types/src/zod/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ function validateComponent(config: unknown) {

All component schemas follow the @objectstack/spec UI specification format:

```typescript
```text
{
// Required
type: string, // Component type identifier
Expand All @@ -243,6 +243,8 @@ All component schemas follow the @objectstack/spec UI specification format:

### Error Messages
```typescript
import { ButtonSchema } from '@object-ui/types/zod';

const result = ButtonSchema.safeParse({
type: 'button',
variant: 'invalid-variant'
Expand All @@ -264,6 +266,8 @@ const result = ButtonSchema.safeParse({

### Nested Validation
```typescript
import { CardSchema } from '@object-ui/types/zod';

// Validates nested components in Card
const cardWithChildren = CardSchema.parse({
type: 'card',
Expand All @@ -279,6 +283,11 @@ const cardWithChildren = CardSchema.parse({

1. **Use safeParse()** for user input validation
```typescript
import { ButtonSchema } from '@object-ui/types/zod';

// Whatever arrived from the form, the request body or the config file.
declare const userInput: unknown;

const result = ButtonSchema.safeParse(userInput);
if (!result.success) {
// Handle errors gracefully
Expand All @@ -287,6 +296,11 @@ const cardWithChildren = CardSchema.parse({

2. **Use parse()** for internal configurations
```typescript
import { ButtonSchema } from '@object-ui/types/zod';

// A configuration your own code produced, so a throw is the right failure.
declare const internalConfig: unknown;

// Throws error on invalid data
const config = ButtonSchema.parse(internalConfig);
```
Expand All @@ -301,10 +315,10 @@ const cardWithChildren = CardSchema.parse({
```typescript
import type { ButtonSchema as ButtonType } from '@object-ui/types';
import { ButtonSchema } from '@object-ui/types/zod';

// Use type for declarations
const config: ButtonType = { ... };
const config: ButtonType = { type: 'button', label: 'Save', variant: 'default' };

// Use schema for validation
ButtonSchema.parse(config);
```
Expand All @@ -322,9 +336,15 @@ Zod schemas are designed for runtime validation:

### With React Hook Form
```typescript
import { zodResolver } from '@hookform/resolvers/zod';
import { FormSchema } from '@object-ui/types/zod';

// `react-hook-form` and `@hookform/resolvers` are YOUR app's dependencies, not
// this package's. These two stand in for `import { useForm } from
// 'react-hook-form'` and `import { zodResolver } from '@hookform/resolvers/zod'`
// so the schema half below is still checked against the shipped types.
declare function useForm(options: { resolver: unknown }): unknown;
declare function zodResolver(schema: unknown): unknown;

const form = useForm({
resolver: zodResolver(FormSchema),
});
Expand Down Expand Up @@ -354,6 +374,9 @@ export async function POST(req: Request) {
```typescript
import { AnyComponentSchema } from '@object-ui/types/zod';

// Your own store of validated configurations.
declare const registry: Map<string, unknown>;

function registerComponent(config: unknown) {
// Validate before registration
const validated = AnyComponentSchema.parse(config);
Expand All @@ -365,16 +388,22 @@ function registerComponent(config: unknown) {

If you're currently using only TypeScript types:

Before — the type alone, checked only where the literal is written:

```typescript
// Before (TypeScript only)
import type { ButtonSchema } from '@object-ui/types';
const button: ButtonSchema = { ... };

// After (with runtime validation)
const button: ButtonSchema = { type: 'button', label: 'Save' };
```

After — the same literal, plus a runtime check at the boundary. Import the type
under an alias, because the Zod twin ships under the same name:

```typescript
import type { ButtonSchema as ButtonType } from '@object-ui/types';
import { ButtonSchema } from '@object-ui/types/zod';

const button: ButtonType = { ... };
const button: ButtonType = { type: 'button', label: 'Save' };
const validated = ButtonSchema.parse(button);
```

Expand Down
63 changes: 47 additions & 16 deletions scripts/__tests__/check-doc-snippet-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -984,39 +984,70 @@ describe('objectui#7308 — the nested package READMEs are in the scan set, ledg
expect(new Set(walked).size).toBe(walked.length);
});

it('the widening is VISIBLE to the accounting: block-bearing pages are ledgered, block-free ones are covered', () => {
/**
* objectui#9412 paid the three rows down, so this pin's direction INVERTED:
* where it used to say "every block-bearing nested page is on the ledger", the
* state it now holds is that NONE of them is, and that every one of them is
* covered. Both readings are the same claim about the accounting — the
* widening is visible in it — and the half that was never about the debt is
* kept verbatim: a nested page with no ts/tsx block is covered at zero blocks
* and may not be ledgered.
*
* ⛔ The inversion is not a relaxation. A ledgered nested page would still be
* legal the day somebody writes a row with a reason (`analyze` re-derives every
* row), and the sibling case below is what keeps the row's SHAPE requirement
* live for that day.
*/
it('the widening is VISIBLE to the accounting: every nested page is covered, none is ledgered', () => {
const state = analyze({}) as {
scans: Map<string, { blocks: unknown[] }>;
covered: string[];
};
const nested = nestedPackageReadmePages(repoRoot);
const withBlocks = nested.filter((doc) => (state.scans.get(doc)?.blocks.length ?? 0) > 0);
const withoutBlocks = nested.filter((doc) => (state.scans.get(doc)?.blocks.length ?? 0) === 0);
// Non-vacuous on BOTH halves — this is the assertion that says why three rows
// were written for four pages.
// Non-vacuous on BOTH halves — a nested page that really holds blocks, and a
// nested page that really holds none, are each present in the tree.
expect(withBlocks.length).toBeGreaterThan(0);
expect(withoutBlocks.length).toBeGreaterThan(0);
expect([...withBlocks].sort()).toEqual(
Object.keys(UNGATED_DOCS as Record<string, string>)
.filter((doc) => nested.includes(doc))
.sort(),
);
// The debt is paid: no nested README is ungated any more.
expect(Object.keys(UNGATED_DOCS as Record<string, string>).filter((doc) => nested.includes(doc))).toEqual([]);
// A page with no ts/tsx block is COVERED at zero blocks and may not be
// ledgered: the stale-entry check would refuse it, which is exactly why
// leaving it out of the surface to keep the ledger short is not available.
for (const doc of withoutBlocks) expect(state.covered).toContain(doc);
// The block-bearing ones are covered now too, and the gate compiles them.
for (const doc of nested) expect(state.covered).toContain(doc);
});

it('every new ledger row carries a measured count, the phases, and what would have to change', () => {
it('every ledger row a nested page might get still owes a measured count, the phases, and what would have to change', () => {
const shapeFailures = (reason: string) =>
[
[/\d+ `tsx?` blocks?/, 'names no block count'],
[/\d+ diagnostics/, 'names no diagnostic count'],
[/TS\d{4}/, 'names no diagnostic code'],
[/syntax-phase|semantic-phase/, 'does not say which phase was measured'],
[/What would have to change|would have to change/, 'does not say what would have to change'],
].flatMap(([pattern, complaint]) => ((pattern as RegExp).test(reason) ? [] : [complaint as string]));

// Non-vacuity, in place of the population this used to loop over: the shape
// checker itself is exercised against a row that satisfies it and one that
// does not, so a nested row reappearing cannot land on a check that has
// quietly stopped checking anything.
expect(
shapeFailures(
'2 `ts` blocks, 4 diagnostics, ALL semantic-phase: TS2304 x4. What would have to change: the ' +
'excerpts declare the values they use.',
),
).toEqual([]);
expect(shapeFailures('this page does not compile')).toHaveLength(5);

// Today: the nested leg carries no ledger row at all (objectui#9412). The
// loop below is what applies the shape the day one returns.
const nested = new Set(nestedPackageReadmePages(repoRoot));
const entries = Object.entries(UNGATED_DOCS as Record<string, string>).filter(([doc]) => nested.has(doc));
expect(entries.length).toBeGreaterThan(0);
expect(entries).toEqual([]);
for (const [doc, reason] of entries) {
expect(reason, `${doc}: names no block count`).toMatch(/\d+ `tsx?` blocks?/);
expect(reason, `${doc}: names no diagnostic count`).toMatch(/\d+ diagnostics/);
expect(reason, `${doc}: names no diagnostic code`).toMatch(/TS\d{4}/);
expect(reason, `${doc}: does not say which phase was measured`).toMatch(/syntax-phase|semantic-phase/);
expect(reason, `${doc}: does not say what would have to change`).toMatch(/What would have to change|would have to change/);
expect(shapeFailures(reason), `${doc}: ${shapeFailures(reason).join('; ')}`).toEqual([]);
}
});

Expand Down
Loading
Loading