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
55 changes: 55 additions & 0 deletions .changeset/spec-symbol-ledger-first-tranche.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
"@object-ui/types": patch
---

refactor(types): bind seven spec-named symbols to the spec instead of re-declaring them — objectstack#4115 ledger burn-down

The `check-spec-symbol-derivation` ledger opened at **156** untriaged collisions.
This is the first tranche: **149** remain, and every symbol removed was *proved*
equivalent to the spec's before being replaced, not assumed equivalent because
its doc comment said so. Four of the seven carried exactly such a comment —
"Mirrors the server's `ImportWriteMode` (`@objectstack/spec`)", "(ObjectStack
Spec v2.0.1)" — which is the claim this issue exists to make true.

Bound as re-exports (`@objectstack/spec/api`, `/kernel`, `/ui`):
`BreakpointName`, `ExportJobStatus`, `ImportJobStatus`, `ImportWriteMode`,
`ValidationError`.

Derived with `z.infer` (`@objectstack/spec/data`): `JoinStrategy`,
`WindowFunction` — the spec exports these as zod enums rather than as types, so
a re-export would not compile against them.

All seven are structurally unchanged, so no consumer changes: the full repo
type-check passes 76/76.

**What decided the tranche.** Mutual assignability (`[Local] extends [Spec]` and
back) looks like the obvious test for "is this a safe re-export", and it lies in
three ways, all of them present in this repo:

- The **spec's own** export resolves to `any` — `NavigationItem`, `JoinNode`,
`FormField`. Binding these would replace a precise local interface with `any`,
a type-safety regression wearing a burn-down's clothes. A naive probe reports
them as "identical to the spec" and recommends exactly the wrong edit.
- The **local** declaration resolves to `any` — recursive zod schemas annotated
`z.ZodType<any>` (`FilterConditionSchema`, `NavigationItemSchema`).
- The local declaration carries `[key: string]: any` — the objectstack#4075
mechanism, which absorbs any extra member so two types compare equal while
accepting wildly different objects (`FormField`, `AppSchema`, `PageSchema`,
`ThemeSchema`, and 12 more).

A zod schema needs one question more than a type does: `FormFieldSchema` has an
**identical `_output` and a divergent `_input`**, so re-exporting it would have
silently changed what authoring input parses. All of this is now written into the
ledger's burn-down instructions, with the detection probe for each case.

`spec-derived-unions.test.ts` gains an **inverted pin** for the three spec-side
`any` cases: it asserts they are *still* `any`. The day the spec types any of
them properly the assertion stops compiling, and the failure is the instruction
to re-run the triage and burn that symbol down.

**Guard fix:** `referencesSpec` walked the declaration's own name node, so a
symbol whose name was also bound to a spec import counted as derived from
itself. TypeScript rejects that particular pair as a duplicate identifier, so it
was not reachable in compiling code — but a guard that depends on the compiler
having run first is a guard with a hole in it. The clean-tree result is
unchanged, confirming it was masking nothing.
76 changes: 75 additions & 1 deletion packages/types/src/__tests__/spec-derived-unions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,19 @@ import {
ActionLocationSchema as SpecActionLocationSchema,
ActionSchema as SpecActionSchema,
} from '@objectstack/spec/ui';
import { FieldType as SpecFieldType } from '@objectstack/spec/data';
import {
FieldType as SpecFieldType,
JoinStrategy as SpecJoinStrategy,
WindowFunction as SpecWindowFunction,
} from '@objectstack/spec/data';
import type { JoinNode as SpecJoinNode } from '@objectstack/spec/data';
import type {
NavigationItem as SpecNavigationItem,
FormField as SpecFormField,
} from '@objectstack/spec/ui';
import type { BreakpointName } from '../mobile';
import type { ExportJobStatus, ImportJobStatus, ImportWriteMode, ValidationError } from '../data';
import type { JoinStrategy, WindowFunction } from '../data-protocol';
import {
OBJECTUI_LOCAL_ACTION_TYPES,
OBJECTUI_LOCAL_PARAM_FIELD_TYPES,
Expand Down Expand Up @@ -112,9 +124,58 @@ const _resolvableCovers = null as unknown as ActionParamFieldType satisfies Reso
// since objectui#3009 made this file compile.)
const _fieldBackedParam: ActionParam = { field: 'status' };
const _minimalTypedParam: ActionParam = { name: 'priority', label: 'Priority', type: 'select' };
// objectstack#4115 ledger burn-down: the symbols whose local declaration was
// PROVED equivalent to the spec's and then replaced by a binding. Each listed
// member is what the local fork carried, so re-declaring it narrower fails here
// as well as at the guard.
const _breakpointCovers = null as unknown as 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' satisfies BreakpointName;
const _importModeCovers = null as unknown as 'insert' | 'update' | 'upsert' satisfies ImportWriteMode;
const _importStatusCovers = null as unknown as
| 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled' satisfies ImportJobStatus;
const _exportStatusCovers = null as unknown as
| 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'expired' satisfies ExportJobStatus;
const _validationErrorShape: ValidationError = { field: 'name', message: 'required' };
// The spec exports these two as zod enums, not types, so objectui derives them
// with `z.infer`. Reading the members back off the schema keeps the check honest
// if the spec widens either enum.
type SpecJoin = typeof SpecJoinStrategy extends { options: readonly (infer T)[] } ? T : never;
const _joinStrategyCovers = null as unknown as SpecJoin satisfies JoinStrategy;
type SpecWindow = typeof SpecWindowFunction extends { options: readonly (infer T)[] } ? T : never;
const _windowFunctionCovers = null as unknown as SpecWindow satisfies WindowFunction;

/**
* Inverted pins — three collisions that are NOT burnable, and the tripwire that
* says when they become burnable.
*
* `NavigationItem`, `JoinNode` and `FormField` collide with a spec export whose
* own declaration resolves to `any` (the spec annotates the recursive schemas
* behind them as `z.ZodType<any>`, and `z.infer` of that is `any`). Binding
* objectui's local interface to the spec would replace a precise, documented
* shape with `any` — a type-safety regression wearing a burn-down's clothes. So
* they stay in the ledger with their local declarations intact, which is the
* right answer for as long as the spec cannot describe them.
*
* Filed upstream as objectstack#4171 (4 of the spec's 2240 exported types).
*
* Mutual assignability CANNOT distinguish this case on its own: `any` answers
* every `extends` question affirmatively, so a naive probe reports these three
* as "identical to the spec" and recommends exactly the wrong edit.
*
* The day the spec types any of them properly, `IsAny<…>` flips to `false`,
* `true satisfies false` stops compiling, and the failure is the instruction:
* re-run the triage and burn that symbol down.
*/
type IsAny<T> = 0 extends 1 & T ? true : false;
const _specNavigationItemIsStillAny = true satisfies IsAny<SpecNavigationItem>;
const _specJoinNodeIsStillAny = true satisfies IsAny<SpecJoinNode>;
const _specFormFieldIsStillAny = true satisfies IsAny<SpecFormField>;

void _chartCovers; void _reportCovers; void _actionCovers; void _pageCovers; void _vizCovers;
void _runnableCovers; void _componentCovers; void _paramFieldCovers; void _resolvableCovers;
void _fieldBackedParam; void _minimalTypedParam;
void _breakpointCovers; void _importModeCovers; void _importStatusCovers; void _exportStatusCovers;
void _validationErrorShape; void _joinStrategyCovers; void _windowFunctionCovers;
void _specNavigationItemIsStillAny; void _specJoinNodeIsStillAny; void _specFormFieldIsStillAny;

/** Read a spec enum's members, failing loudly if the shape ever changes. */
const optionsOf = (schema: unknown, name: string): string[] => {
Expand Down Expand Up @@ -250,4 +311,17 @@ describe('unions derived from a spec vocabulary stay derived (#2944)', () => {
const local = ['grid', 'gallery', 'kanban', 'calendar', 'timeline'];
expect(local.filter((v) => spec.includes(v))).toEqual([]);
});

it('JoinStrategy / WindowFunction come off the spec enum, not a restatement (objectstack#4115)', () => {
// The type aliases erase, so the runtime witness is the schema they are
// `z.infer`-ed from. Both were hand-written unions carrying a "(ObjectStack
// Spec v2.0.1)" doc header — a version claim nothing checked.
const joins = optionsOf(SpecJoinStrategy, 'JoinStrategy');
expect(joins).toEqual(expect.arrayContaining(['auto', 'database', 'hash', 'loop']));

const windows = optionsOf(SpecWindowFunction, 'WindowFunction');
for (const member of ['row_number', 'rank', 'dense_rank', 'lag', 'lead', 'sum', 'count']) {
expect(windows).toContain(member);
}
});
});
32 changes: 14 additions & 18 deletions packages/types/src/data-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@
import type { SortConfig as BaseSortConfig } from './objectql';
import type { FilterOperator as BaseFilterOperator } from './complex';

// Spec-owned vocabulary, bound rather than re-declared (objectstack#4115). The
// spec exports both of these as zod enums, not as types, so they are derived
// through `z.infer` — `export type { … } from` would not compile against a value.
import type { z } from 'zod';
import type {
JoinStrategy as SpecJoinStrategy,
WindowFunction as SpecWindowFunction,
} from '@objectstack/spec/data';

/**
* =============================================================================
* Phase 3.3: QuerySchema AST Implementation
Expand Down Expand Up @@ -81,9 +90,9 @@ export interface WhereNode extends QueryASTNode {
}

/**
* Join execution strategy hint (ObjectStack Spec v2.0.1)
* Join execution strategy hint — derived from the spec's `JoinStrategy` zod enum.
*/
export type JoinStrategy = 'auto' | 'database' | 'hash' | 'loop';
export type JoinStrategy = z.infer<typeof SpecJoinStrategy>;

/**
* JOIN clause node (Phase 3.3.4)
Expand Down Expand Up @@ -155,22 +164,9 @@ export interface AggregateNode extends QueryASTNode {
}

/**
* Window function type (ObjectStack Spec v2.0.1)
*/
export type WindowFunction =
| 'row_number'
| 'rank'
| 'dense_rank'
| 'percent_rank'
| 'lag'
| 'lead'
| 'first_value'
| 'last_value'
| 'sum'
| 'avg'
| 'count'
| 'min'
| 'max';
* Window function type — derived from the spec's `WindowFunction` zod enum.
*/
export type WindowFunction = z.infer<typeof SpecWindowFunction>;

/**
* Window frame unit (ObjectStack Spec v2.0.1)
Expand Down
54 changes: 17 additions & 37 deletions packages/types/src/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@
* @packageDocumentation
*/

// Spec-owned names are bound here, not re-declared (objectstack#4115). Each of
// these already carried a doc comment claiming it mirrored the spec; the import
// is what makes the claim true, and what makes a spec change break the build
// instead of drifting quietly.
import type { ExportJobStatus, ImportJobStatus, ImportWriteMode } from '@objectstack/spec/api';
import type { ValidationError } from '@objectstack/spec/kernel';

export type { ExportJobStatus, ImportJobStatus, ImportWriteMode, ValidationError };

/**
* Query parameters for data fetching.
* Follows OData/REST conventions for universal compatibility.
Expand Down Expand Up @@ -773,13 +782,12 @@ export interface DataSource<T = any> {
}

/**
* How each incoming import row is committed against existing data. Mirrors the
* server's `ImportWriteMode` (`@objectstack/spec`).
* How each incoming import row is committed against existing data. Imported from
* `@objectstack/spec/api` at the top of this module.
* - `insert` — always create a new record (default; ignores `matchFields`)
* - `update` — update the record matched by `matchFields`; skip when none match
* - `upsert` — update when matched, else create
*/
export type ImportWriteMode = 'insert' | 'update' | 'upsert';

/**
* A single source-column → target-field mapping with optional per-column
Expand Down Expand Up @@ -875,15 +883,9 @@ export interface ImportRecordsResult {
}

/**
* Lifecycle status of an asynchronous import job. Mirrors the server's
* `ImportJobStatus` enum (`@objectstack/spec`).
* Lifecycle status of an asynchronous import job. Imported from
* `@objectstack/spec/api` at the top of this module.
*/
export type ImportJobStatus =
| 'pending'
| 'running'
| 'succeeded'
| 'failed'
| 'cancelled';

/**
* Result of {@link DataSource.createImportJob}. `jobId` is the polling key.
Expand Down Expand Up @@ -1024,16 +1026,9 @@ export interface ExportDownloadRequest {
}

/**
* Lifecycle status of a server-driven export job.
* Mirrors the `ExportJobStatus` enum from `@objectstack/spec/export`.
* Lifecycle status of a server-driven export job. Imported from
* `@objectstack/spec/api` at the top of this module.
*/
export type ExportJobStatus =
| 'pending'
| 'processing'
| 'completed'
| 'failed'
| 'cancelled'
| 'expired';

/**
* Output formats supported by async export jobs.
Expand Down Expand Up @@ -1256,24 +1251,9 @@ export interface DataBinding {
}

/**
* Validation error
* Validation error — imported from `@objectstack/spec/kernel` at the top of this
* module.
*/
export interface ValidationError {
/**
* Field name
*/
field: string;

/**
* Error message
*/
message: string;

/**
* Error code
*/
code?: string;
}

/**
* API error response
Expand Down
12 changes: 10 additions & 2 deletions packages/types/src/mobile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,20 @@
* @packageDocumentation
*/

import type { BreakpointName } from '@objectstack/spec/ui';

// ============================================================================
// Responsive Configuration
// ============================================================================

/** Breakpoint names */
export type BreakpointName = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
/**
* Breakpoint names.
*
* Bound to the spec rather than re-declared (objectstack#4115): a local union
* under a spec export's name is read by the next reader as the spec's own
* definition, so a copy that is correct today is a planted premise tomorrow.
*/
export type { BreakpointName };

/** Responsive value - different values for different breakpoints */
export type ResponsiveValue<T> = T | Partial<Record<BreakpointName, T>>;
Expand Down
Loading
Loading