Skip to content

Commit 3a6cf24

Browse files
authored
refactor(types): burn down seven spec-symbol ledger entries, with the triage that decides the rest (#3042)
objectstack#4115's ledger opened at 156 untriaged collisions; 149 remain. Re-exported: BreakpointName, ExportJobStatus, ImportJobStatus, ImportWriteMode, ValidationError. Derived with z.infer: JoinStrategy, WindowFunction, which the spec exports as zod enums rather than as types. All seven are structurally unchanged; full repo type-check passes 76/76. The durable output is the triage. Mutual assignability is the obvious test for "safe re-export" and it lies three ways, all present in this repo: - The SPEC's export resolves to `any` (NavigationItem, JoinNode, FormField) — binding these replaces a precise local interface with `any`. Filed upstream as objectstack#4171: 4 of the spec's 2240 exported types, all recursive schemas annotated z.ZodType<any>. - The LOCAL declaration resolves to `any` (FilterConditionSchema, NavigationItemSchema). - The local declaration carries `[key: string]: any` — the objectstack#4075 mechanism (FormField, AppSchema, PageSchema, ThemeSchema, +12). A zod schema needs one question more than a type: FormFieldSchema has an identical _output and a divergent _input, so re-exporting it would have silently changed what authoring input parses. Detection probes for all four are now in the ledger's burn-down instructions. spec-derived-unions.test.ts gains an inverted pin asserting the three spec-side `any` cases are STILL `any`, so fixing objectstack#4171 automatically fails objectui CI and names the symbol that can now be burned 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. Unreachable in compiling code, but a guard that depends on the compiler having run first has a hole in it. Clean-tree result unchanged, confirming it masked nothing.
1 parent 4e11db8 commit 3a6cf24

6 files changed

Lines changed: 222 additions & 70 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@object-ui/types": patch
3+
---
4+
5+
refactor(types): bind seven spec-named symbols to the spec instead of re-declaring them — objectstack#4115 ledger burn-down
6+
7+
The `check-spec-symbol-derivation` ledger opened at **156** untriaged collisions.
8+
This is the first tranche: **149** remain, and every symbol removed was *proved*
9+
equivalent to the spec's before being replaced, not assumed equivalent because
10+
its doc comment said so. Four of the seven carried exactly such a comment —
11+
"Mirrors the server's `ImportWriteMode` (`@objectstack/spec`)", "(ObjectStack
12+
Spec v2.0.1)" — which is the claim this issue exists to make true.
13+
14+
Bound as re-exports (`@objectstack/spec/api`, `/kernel`, `/ui`):
15+
`BreakpointName`, `ExportJobStatus`, `ImportJobStatus`, `ImportWriteMode`,
16+
`ValidationError`.
17+
18+
Derived with `z.infer` (`@objectstack/spec/data`): `JoinStrategy`,
19+
`WindowFunction` — the spec exports these as zod enums rather than as types, so
20+
a re-export would not compile against them.
21+
22+
All seven are structurally unchanged, so no consumer changes: the full repo
23+
type-check passes 76/76.
24+
25+
**What decided the tranche.** Mutual assignability (`[Local] extends [Spec]` and
26+
back) looks like the obvious test for "is this a safe re-export", and it lies in
27+
three ways, all of them present in this repo:
28+
29+
- The **spec's own** export resolves to `any``NavigationItem`, `JoinNode`,
30+
`FormField`. Binding these would replace a precise local interface with `any`,
31+
a type-safety regression wearing a burn-down's clothes. A naive probe reports
32+
them as "identical to the spec" and recommends exactly the wrong edit.
33+
- The **local** declaration resolves to `any` — recursive zod schemas annotated
34+
`z.ZodType<any>` (`FilterConditionSchema`, `NavigationItemSchema`).
35+
- The local declaration carries `[key: string]: any` — the objectstack#4075
36+
mechanism, which absorbs any extra member so two types compare equal while
37+
accepting wildly different objects (`FormField`, `AppSchema`, `PageSchema`,
38+
`ThemeSchema`, and 12 more).
39+
40+
A zod schema needs one question more than a type does: `FormFieldSchema` has an
41+
**identical `_output` and a divergent `_input`**, so re-exporting it would have
42+
silently changed what authoring input parses. All of this is now written into the
43+
ledger's burn-down instructions, with the detection probe for each case.
44+
45+
`spec-derived-unions.test.ts` gains an **inverted pin** for the three spec-side
46+
`any` cases: it asserts they are *still* `any`. The day the spec types any of
47+
them properly the assertion stops compiling, and the failure is the instruction
48+
to re-run the triage and burn that symbol down.
49+
50+
**Guard fix:** `referencesSpec` walked the declaration's own name node, so a
51+
symbol whose name was also bound to a spec import counted as derived from
52+
itself. TypeScript rejects that particular pair as a duplicate identifier, so it
53+
was not reachable in compiling code — but a guard that depends on the compiler
54+
having run first is a guard with a hole in it. The clean-tree result is
55+
unchanged, confirming it was masking nothing.

packages/types/src/__tests__/spec-derived-unions.test.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,19 @@ import {
5858
ActionLocationSchema as SpecActionLocationSchema,
5959
ActionSchema as SpecActionSchema,
6060
} from '@objectstack/spec/ui';
61-
import { FieldType as SpecFieldType } from '@objectstack/spec/data';
61+
import {
62+
FieldType as SpecFieldType,
63+
JoinStrategy as SpecJoinStrategy,
64+
WindowFunction as SpecWindowFunction,
65+
} from '@objectstack/spec/data';
66+
import type { JoinNode as SpecJoinNode } from '@objectstack/spec/data';
67+
import type {
68+
NavigationItem as SpecNavigationItem,
69+
FormField as SpecFormField,
70+
} from '@objectstack/spec/ui';
71+
import type { BreakpointName } from '../mobile';
72+
import type { ExportJobStatus, ImportJobStatus, ImportWriteMode, ValidationError } from '../data';
73+
import type { JoinStrategy, WindowFunction } from '../data-protocol';
6274
import {
6375
OBJECTUI_LOCAL_ACTION_TYPES,
6476
OBJECTUI_LOCAL_PARAM_FIELD_TYPES,
@@ -112,9 +124,58 @@ const _resolvableCovers = null as unknown as ActionParamFieldType satisfies Reso
112124
// since objectui#3009 made this file compile.)
113125
const _fieldBackedParam: ActionParam = { field: 'status' };
114126
const _minimalTypedParam: ActionParam = { name: 'priority', label: 'Priority', type: 'select' };
127+
// objectstack#4115 ledger burn-down: the symbols whose local declaration was
128+
// PROVED equivalent to the spec's and then replaced by a binding. Each listed
129+
// member is what the local fork carried, so re-declaring it narrower fails here
130+
// as well as at the guard.
131+
const _breakpointCovers = null as unknown as 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' satisfies BreakpointName;
132+
const _importModeCovers = null as unknown as 'insert' | 'update' | 'upsert' satisfies ImportWriteMode;
133+
const _importStatusCovers = null as unknown as
134+
| 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled' satisfies ImportJobStatus;
135+
const _exportStatusCovers = null as unknown as
136+
| 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'expired' satisfies ExportJobStatus;
137+
const _validationErrorShape: ValidationError = { field: 'name', message: 'required' };
138+
// The spec exports these two as zod enums, not types, so objectui derives them
139+
// with `z.infer`. Reading the members back off the schema keeps the check honest
140+
// if the spec widens either enum.
141+
type SpecJoin = typeof SpecJoinStrategy extends { options: readonly (infer T)[] } ? T : never;
142+
const _joinStrategyCovers = null as unknown as SpecJoin satisfies JoinStrategy;
143+
type SpecWindow = typeof SpecWindowFunction extends { options: readonly (infer T)[] } ? T : never;
144+
const _windowFunctionCovers = null as unknown as SpecWindow satisfies WindowFunction;
145+
146+
/**
147+
* Inverted pins — three collisions that are NOT burnable, and the tripwire that
148+
* says when they become burnable.
149+
*
150+
* `NavigationItem`, `JoinNode` and `FormField` collide with a spec export whose
151+
* own declaration resolves to `any` (the spec annotates the recursive schemas
152+
* behind them as `z.ZodType<any>`, and `z.infer` of that is `any`). Binding
153+
* objectui's local interface to the spec would replace a precise, documented
154+
* shape with `any` — a type-safety regression wearing a burn-down's clothes. So
155+
* they stay in the ledger with their local declarations intact, which is the
156+
* right answer for as long as the spec cannot describe them.
157+
*
158+
* Filed upstream as objectstack#4171 (4 of the spec's 2240 exported types).
159+
*
160+
* Mutual assignability CANNOT distinguish this case on its own: `any` answers
161+
* every `extends` question affirmatively, so a naive probe reports these three
162+
* as "identical to the spec" and recommends exactly the wrong edit.
163+
*
164+
* The day the spec types any of them properly, `IsAny<…>` flips to `false`,
165+
* `true satisfies false` stops compiling, and the failure is the instruction:
166+
* re-run the triage and burn that symbol down.
167+
*/
168+
type IsAny<T> = 0 extends 1 & T ? true : false;
169+
const _specNavigationItemIsStillAny = true satisfies IsAny<SpecNavigationItem>;
170+
const _specJoinNodeIsStillAny = true satisfies IsAny<SpecJoinNode>;
171+
const _specFormFieldIsStillAny = true satisfies IsAny<SpecFormField>;
172+
115173
void _chartCovers; void _reportCovers; void _actionCovers; void _pageCovers; void _vizCovers;
116174
void _runnableCovers; void _componentCovers; void _paramFieldCovers; void _resolvableCovers;
117175
void _fieldBackedParam; void _minimalTypedParam;
176+
void _breakpointCovers; void _importModeCovers; void _importStatusCovers; void _exportStatusCovers;
177+
void _validationErrorShape; void _joinStrategyCovers; void _windowFunctionCovers;
178+
void _specNavigationItemIsStillAny; void _specJoinNodeIsStillAny; void _specFormFieldIsStillAny;
118179

119180
/** Read a spec enum's members, failing loudly if the shape ever changes. */
120181
const optionsOf = (schema: unknown, name: string): string[] => {
@@ -250,4 +311,17 @@ describe('unions derived from a spec vocabulary stay derived (#2944)', () => {
250311
const local = ['grid', 'gallery', 'kanban', 'calendar', 'timeline'];
251312
expect(local.filter((v) => spec.includes(v))).toEqual([]);
252313
});
314+
315+
it('JoinStrategy / WindowFunction come off the spec enum, not a restatement (objectstack#4115)', () => {
316+
// The type aliases erase, so the runtime witness is the schema they are
317+
// `z.infer`-ed from. Both were hand-written unions carrying a "(ObjectStack
318+
// Spec v2.0.1)" doc header — a version claim nothing checked.
319+
const joins = optionsOf(SpecJoinStrategy, 'JoinStrategy');
320+
expect(joins).toEqual(expect.arrayContaining(['auto', 'database', 'hash', 'loop']));
321+
322+
const windows = optionsOf(SpecWindowFunction, 'WindowFunction');
323+
for (const member of ['row_number', 'rank', 'dense_rank', 'lag', 'lead', 'sum', 'count']) {
324+
expect(windows).toContain(member);
325+
}
326+
});
253327
});

packages/types/src/data-protocol.ts

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@
2020
import type { SortConfig as BaseSortConfig } from './objectql';
2121
import type { FilterOperator as BaseFilterOperator } from './complex';
2222

23+
// Spec-owned vocabulary, bound rather than re-declared (objectstack#4115). The
24+
// spec exports both of these as zod enums, not as types, so they are derived
25+
// through `z.infer` — `export type { … } from` would not compile against a value.
26+
import type { z } from 'zod';
27+
import type {
28+
JoinStrategy as SpecJoinStrategy,
29+
WindowFunction as SpecWindowFunction,
30+
} from '@objectstack/spec/data';
31+
2332
/**
2433
* =============================================================================
2534
* Phase 3.3: QuerySchema AST Implementation
@@ -81,9 +90,9 @@ export interface WhereNode extends QueryASTNode {
8190
}
8291

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

8897
/**
8998
* JOIN clause node (Phase 3.3.4)
@@ -155,22 +164,9 @@ export interface AggregateNode extends QueryASTNode {
155164
}
156165

157166
/**
158-
* Window function type (ObjectStack Spec v2.0.1)
159-
*/
160-
export type WindowFunction =
161-
| 'row_number'
162-
| 'rank'
163-
| 'dense_rank'
164-
| 'percent_rank'
165-
| 'lag'
166-
| 'lead'
167-
| 'first_value'
168-
| 'last_value'
169-
| 'sum'
170-
| 'avg'
171-
| 'count'
172-
| 'min'
173-
| 'max';
167+
* Window function type — derived from the spec's `WindowFunction` zod enum.
168+
*/
169+
export type WindowFunction = z.infer<typeof SpecWindowFunction>;
174170

175171
/**
176172
* Window frame unit (ObjectStack Spec v2.0.1)

packages/types/src/data.ts

Lines changed: 17 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@
1616
* @packageDocumentation
1717
*/
1818

19+
// Spec-owned names are bound here, not re-declared (objectstack#4115). Each of
20+
// these already carried a doc comment claiming it mirrored the spec; the import
21+
// is what makes the claim true, and what makes a spec change break the build
22+
// instead of drifting quietly.
23+
import type { ExportJobStatus, ImportJobStatus, ImportWriteMode } from '@objectstack/spec/api';
24+
import type { ValidationError } from '@objectstack/spec/kernel';
25+
26+
export type { ExportJobStatus, ImportJobStatus, ImportWriteMode, ValidationError };
27+
1928
/**
2029
* Query parameters for data fetching.
2130
* Follows OData/REST conventions for universal compatibility.
@@ -773,13 +782,12 @@ export interface DataSource<T = any> {
773782
}
774783

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

784792
/**
785793
* A single source-column → target-field mapping with optional per-column
@@ -875,15 +883,9 @@ export interface ImportRecordsResult {
875883
}
876884

877885
/**
878-
* Lifecycle status of an asynchronous import job. Mirrors the server's
879-
* `ImportJobStatus` enum (`@objectstack/spec`).
886+
* Lifecycle status of an asynchronous import job. Imported from
887+
* `@objectstack/spec/api` at the top of this module.
880888
*/
881-
export type ImportJobStatus =
882-
| 'pending'
883-
| 'running'
884-
| 'succeeded'
885-
| 'failed'
886-
| 'cancelled';
887889

888890
/**
889891
* Result of {@link DataSource.createImportJob}. `jobId` is the polling key.
@@ -1024,16 +1026,9 @@ export interface ExportDownloadRequest {
10241026
}
10251027

10261028
/**
1027-
* Lifecycle status of a server-driven export job.
1028-
* Mirrors the `ExportJobStatus` enum from `@objectstack/spec/export`.
1029+
* Lifecycle status of a server-driven export job. Imported from
1030+
* `@objectstack/spec/api` at the top of this module.
10291031
*/
1030-
export type ExportJobStatus =
1031-
| 'pending'
1032-
| 'processing'
1033-
| 'completed'
1034-
| 'failed'
1035-
| 'cancelled'
1036-
| 'expired';
10371032

10381033
/**
10391034
* Output formats supported by async export jobs.
@@ -1256,24 +1251,9 @@ export interface DataBinding {
12561251
}
12571252

12581253
/**
1259-
* Validation error
1254+
* Validation error — imported from `@objectstack/spec/kernel` at the top of this
1255+
* module.
12601256
*/
1261-
export interface ValidationError {
1262-
/**
1263-
* Field name
1264-
*/
1265-
field: string;
1266-
1267-
/**
1268-
* Error message
1269-
*/
1270-
message: string;
1271-
1272-
/**
1273-
* Error code
1274-
*/
1275-
code?: string;
1276-
}
12771257

12781258
/**
12791259
* API error response

packages/types/src/mobile.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,20 @@
1616
* @packageDocumentation
1717
*/
1818

19+
import type { BreakpointName } from '@objectstack/spec/ui';
20+
1921
// ============================================================================
2022
// Responsive Configuration
2123
// ============================================================================
2224

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

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

0 commit comments

Comments
 (0)