diff --git a/.changeset/spec-symbol-ledger-batch-1.md b/.changeset/spec-symbol-ledger-batch-1.md new file mode 100644 index 0000000000..e4d759f5b0 --- /dev/null +++ b/.changeset/spec-symbol-ledger-batch-1.md @@ -0,0 +1,48 @@ +--- +"@object-ui/types": minor +"@object-ui/core": minor +--- + +Report / chart / query symbols stop wearing `@objectstack/spec`'s names +(objectui#3155, objectstack#4115). + +**Breaking for TypeScript imports** — six exported names change. Each was a +different concept than the spec export it collided with, so an author reading +the objectui declaration as "the spec's" was reading a false claim: + +| was | now | why they were never the same thing | +|:--|:--|:--| +| `ChartSeries` | `ChartDataSeries` | ours is a display name plus literal `data: number[]`; the spec's is a dataset-bound series descriptor (`type`/`stack`/`yAxis`/`variant`) with no data at all | +| `ChartSeriesSchema` | `ChartDataSeriesSchema` | zod twin of the above | +| `QueryAST` | `SqlQueryAST` | ours is a compiled SQL syntax tree (`select`/`from`/`join`/`group_by`); the spec's is the ObjectQL request descriptor (`object`/`fields`/`where`/`expand`) | +| `QuerySchema` | `DriverQueryConfig` | ours is the high-level config `QueryASTBuilder` compiles; the spec exports that name as a zod schema value | +| `DriverInterface` | `SqlDriverInterface` | ours is objectui's SQL-oriented client abstraction (`query(sql, params)`); the spec's is the platform runtime driver contract | +| `DatasourceSchema` | `DatasourceRegistration` | ours is the in-memory record `DatasourceManager` holds — its `driver` is a live instance; the spec's is the authored metadata document, where `driver` is a name | + +Three more are now DERIVED from the spec instead of hand-restated, which fixes +live silent-stripping defects, since a `z.object()` drops unknown keys: + +- **`DashboardWidgetSchema`** declared 10 of the spec's 22 keys, so + `objectui validate` deleted the other 12 without a word — `chartConfig`, + `colorVariant`, `filter`, `responsive`, `aria`, + `actionUrl`/`actionType`/`actionIcon`, `compareTo`, `suppressWarnings` and the + `requiresObject` / `requiresService` capability gates the dashboard renderer + honours at runtime. The TS interface had declared most of them all along, so a + widget could type-check and still lose half its configuration on validation. + Pinned divergences kept: `id` stays optional, `type` stays widened for the + objectui-only `list` / `custom` families, and the legacy `component` envelope + stays. +- **`GlobalFilterSchema`** took `scope` as a free-form string (any typo + validated); it now uses the spec's `widget | dashboard` vocabulary. The three + objectui widenings that back a real runtime normalizer are kept and pinned: + the bare-string `options` shorthand, the normalized `{ preset }` date default, + and an optional `optionsFrom.labelField`. +- **`AppContextSelectorSchema`** was a full restatement; spec keys and their + defaults now flow in by reference, with `label` widened for objectui's i18n + label envelope — which `AppContextSelectors` already renders. + +`ListViewSchema`'s zod node now names the spec in its own initializer rather +than one hop away through a local const, so its long-standing derivation is +visible where it is declared. + +Drift guard: `packages/types/src/__tests__/report-chart-query-spec-parity.test.ts`. diff --git a/packages/core/src/query/__tests__/query-ast.test.ts b/packages/core/src/query/__tests__/query-ast.test.ts index 7de5b4a0f7..2ab9c8c47a 100644 --- a/packages/core/src/query/__tests__/query-ast.test.ts +++ b/packages/core/src/query/__tests__/query-ast.test.ts @@ -4,14 +4,14 @@ import { describe, it, expect } from 'vitest'; import { QueryASTBuilder } from '../query-ast'; -import type { QuerySchema } from '@object-ui/types'; +import type { DriverQueryConfig } from '@object-ui/types'; describe('QueryASTBuilder', () => { const builder = new QueryASTBuilder(); describe('Basic Query Building', () => { it('should build simple SELECT query', () => { - const query: QuerySchema = { + const query: DriverQueryConfig = { object: 'users', fields: ['id', 'name', 'email'], }; @@ -24,7 +24,7 @@ describe('QueryASTBuilder', () => { }); it('should build SELECT * when no fields specified', () => { - const query: QuerySchema = { + const query: DriverQueryConfig = { object: 'users', }; @@ -38,7 +38,7 @@ describe('QueryASTBuilder', () => { }); it('should build query with WHERE clause', () => { - const query: QuerySchema = { + const query: DriverQueryConfig = { object: 'users', fields: ['id', 'name'], filter: { @@ -60,7 +60,7 @@ describe('QueryASTBuilder', () => { }); it('should build query with ORDER BY', () => { - const query: QuerySchema = { + const query: DriverQueryConfig = { object: 'users', fields: ['id', 'name'], sort: [ @@ -77,7 +77,7 @@ describe('QueryASTBuilder', () => { }); it('should build query with LIMIT and OFFSET', () => { - const query: QuerySchema = { + const query: DriverQueryConfig = { object: 'users', fields: ['id', 'name'], limit: 10, @@ -95,7 +95,7 @@ describe('QueryASTBuilder', () => { describe('Advanced Query Building', () => { it('should build query with JOIN', () => { - const query: QuerySchema = { + const query: DriverQueryConfig = { object: 'users', fields: ['id', 'name', 'orders.total'], joins: [ @@ -119,7 +119,7 @@ describe('QueryASTBuilder', () => { }); it('should build query with aggregations', () => { - const query: QuerySchema = { + const query: DriverQueryConfig = { object: 'orders', aggregations: [ { @@ -145,7 +145,7 @@ describe('QueryASTBuilder', () => { }); it('should build query with GROUP BY', () => { - const query: QuerySchema = { + const query: DriverQueryConfig = { object: 'orders', fields: ['user_id'], group_by: ['user_id'], @@ -170,7 +170,7 @@ describe('QueryASTBuilder', () => { describe('Complex Filters', () => { it('should build query with nested AND/OR filters', () => { - const query: QuerySchema = { + const query: DriverQueryConfig = { object: 'users', filter: { operator: 'and', diff --git a/packages/core/src/query/query-ast.ts b/packages/core/src/query/query-ast.ts index 275712d209..7076ab0635 100644 --- a/packages/core/src/query/query-ast.ts +++ b/packages/core/src/query/query-ast.ts @@ -1,12 +1,12 @@ /** * ObjectUI - Query AST Builder - * Phase 3.3: QuerySchema AST implementation + * Phase 3.3: DriverQueryConfig AST implementation * ObjectStack Spec v2.0.1: Window functions support */ import type { - QueryAST, - QuerySchema, + SqlQueryAST, + DriverQueryConfig, SelectNode, FromNode, WhereNode, @@ -32,11 +32,11 @@ import type { } from '@object-ui/types'; /** - * Query AST Builder - Converts QuerySchema to AST + * Query AST Builder - Converts DriverQueryConfig to AST */ export class QueryASTBuilder { - build(query: QuerySchema): QueryAST { - const ast: QueryAST = { + build(query: DriverQueryConfig): SqlQueryAST { + const ast: SqlQueryAST = { select: this.buildSelect(query), from: this.buildFrom(query), }; @@ -68,7 +68,7 @@ export class QueryASTBuilder { return ast; } - private buildSelect(query: QuerySchema): SelectNode { + private buildSelect(query: DriverQueryConfig): SelectNode { const fields: (FieldNode | AggregateNode | WindowNode)[] = []; if (query.fields && query.fields.length > 0) { @@ -94,7 +94,7 @@ export class QueryASTBuilder { }; } - private buildFrom(query: QuerySchema): FromNode { + private buildFrom(query: DriverQueryConfig): FromNode { return { type: 'from', table: query.object, @@ -329,13 +329,13 @@ export class QueryASTBuilder { return node; } - optimize(ast: QueryAST): QueryAST { + optimize(ast: SqlQueryAST): SqlQueryAST { return ast; } } export const defaultQueryASTBuilder = new QueryASTBuilder(); -export function buildQueryAST(query: QuerySchema): QueryAST { +export function buildQueryAST(query: DriverQueryConfig): SqlQueryAST { return defaultQueryASTBuilder.build(query); } diff --git a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts new file mode 100644 index 0000000000..d3dbd2b34f --- /dev/null +++ b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts @@ -0,0 +1,351 @@ +/** + * 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. + */ + +/** + * Report / chart / query cluster ↔ `@objectstack/spec` drift guard + * (objectui#3155, objectstack#4115). + * + * This file guards the eleven collisions of ledger batch 1, which resolved three + * different ways — and the tests differ accordingly: + * + * - **Derived** (`AppContextSelectorSchema`, `GlobalFilterSchema`, + * `DashboardWidgetSchema`): spec keys now flow in by reference, so the risk is + * no longer drift but SHADOWING — a local key quietly reclaiming a name the + * spec owns, or a divergence outliving the reason it was granted. Four-way + * parity, in the shape `select-option-spec-parity.test.ts` established. + * - **Renamed** (`ChartDataSeries`, `ChartDataSeriesSchema`, `SqlQueryAST`, + * `DriverQueryConfig`, `SqlDriverInterface`, `DatasourceRegistration`): the + * concepts genuinely differ from the spec exports whose names they were + * wearing. The risk is picking a new name the spec ALREADY owns — the + * `PageComponentSchema` mistake from objectui#3074 — so each new name is + * asserted absent from the spec's export set, types and values alike. + * - **Not burnable** (`JoinedReportBlock`): kept in the ledger behind an + * inverted pin, see the bottom of this file. + */ + +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import ts from 'typescript'; +import { + AppContextSelectorSchema as SpecAppContextSelectorSchema, + GlobalFilterSchema as SpecGlobalFilterSchema, + DashboardWidgetSchema as SpecDashboardWidgetSchema, +} from '@objectstack/spec/ui'; +import type { JoinedReportBlock as SpecJoinedReportBlock } from '@objectstack/spec/ui'; +import { AppContextSelectorSchema } from '../zod/app.zod.js'; +import { DashboardWidgetSchema, GlobalFilterSchema } from '../zod/complex.zod.js'; + +const shapeOf = (s: unknown) => (s as { shape: Record }).shape; + +// ───────────────────────────────────────────────────────────────────────────── +// AppContextSelectorSchema +// ───────────────────────────────────────────────────────────────────────────── + +describe('AppContextSelectorSchema derives from the spec', () => { + const specKeys = Object.keys(shapeOf(SpecAppContextSelectorSchema)).sort(); + const localKeys = Object.keys(shapeOf(AppContextSelectorSchema)).sort(); + + it('carries every spec key', () => { + for (const key of specKeys) { + expect(localKeys, `spec key '${key}' missing locally`).toContain(key); + } + }); + + it('adds no local key of its own', () => { + // The only sanctioned divergence is a RETYPE of `label`, not an extension. + // A new name appearing here means someone extended the selector locally + // instead of promoting the field upstream. + expect(localKeys.filter((k) => !specKeys.includes(k))).toEqual([]); + }); + + it('keeps the spec keys the old hand copy restated, defaults included', () => { + const parsed = AppContextSelectorSchema.parse({ + id: 'active_package', + label: 'Package', + optionsSource: { endpoint: '/api/packages' }, + }); + expect(parsed.optionsSource.valueKey).toBe('id'); + expect(parsed.optionsSource.labelKey).toBe('name'); + expect(parsed.includeAll).toBe(true); + expect(parsed.persist).toBe('query'); + expect(parsed.placement).toBe('sidebar_header'); + }); +}); + +describe('AppContextSelectorSchema pinned divergence', () => { + const base = { id: 'pkg', optionsSource: { endpoint: '/api/x' } }; + + it('widens `label` to objectui\'s i18n envelope on purpose', () => { + // `AppContextSelectors` (@object-ui/app-shell) renders the label through + // `resolveI18nLabel`, so a localized selector must validate here… + const i18n = { default: 'Package', translations: { 'zh-CN': '安装包' } }; + expect(AppContextSelectorSchema.safeParse({ ...base, label: i18n }).success).toBe(true); + // …while the spec (plain `z.string()`) rejects it. If the spec ever widens + // `label` itself, this flips and the local override is what to delete. + expect(SpecAppContextSelectorSchema.safeParse({ ...base, label: i18n }).success).toBe(false); + }); + + it('still accepts the spec\'s plain-string label', () => { + expect(AppContextSelectorSchema.safeParse({ ...base, label: 'Package' }).success).toBe(true); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// GlobalFilterSchema +// ───────────────────────────────────────────────────────────────────────────── + +describe('GlobalFilterSchema derives from the spec', () => { + const specKeys = Object.keys(shapeOf(SpecGlobalFilterSchema)).sort(); + const localKeys = Object.keys(shapeOf(GlobalFilterSchema)).sort(); + + it('carries every spec key', () => { + for (const key of specKeys) { + expect(localKeys, `spec key '${key}' missing locally`).toContain(key); + } + }); + + it('adds no local key of its own', () => { + expect(localKeys.filter((k) => !specKeys.includes(k))).toEqual([]); + }); + + it('picks up the spec `scope` vocabulary by reference', () => { + // Previously `z.string().optional()` — any typo validated. Nothing reads + // `scope` at runtime, so adopting the spec enum costs nothing and closes it. + const parsed = GlobalFilterSchema.parse({ field: 'stage', scope: 'dashboard' }); + expect(parsed.scope).toBe('dashboard'); + expect(GlobalFilterSchema.safeParse({ field: 'stage', scope: 'globl' }).success).toBe(false); + }); +}); + +describe('GlobalFilterSchema pinned divergences', () => { + it('accepts the bare-string options shorthand the runtime normalizes', () => { + // `normalizeFilterOptions` (@object-ui/core dashboard-filters.ts) folds both + // spellings into the spec's `{ value, label }` form. + expect(GlobalFilterSchema.safeParse({ field: 'region', options: ['EMEA', 'APAC'] }).success).toBe(true); + expect(GlobalFilterSchema.safeParse({ field: 'region', options: [{ value: 'emea' }] }).success).toBe(true); + // The spec requires objects WITH a label. If it ever relaxes, drop the override. + expect(SpecGlobalFilterSchema.safeParse({ field: 'region', options: ['EMEA'] }).success).toBe(false); + expect(SpecGlobalFilterSchema.safeParse({ field: 'region', options: [{ value: 'emea' }] }).success).toBe(false); + }); + + it('accepts the normalized date-preset default object', () => { + // framework#4475: `normalizeDateDefault` lifts a preset NAME into `{ preset }`, + // and stored dashboards carry that object form. + expect(GlobalFilterSchema.safeParse({ + field: 'created_at', type: 'date', defaultValue: { preset: 'last_7_days' }, + }).success).toBe(true); + expect(SpecGlobalFilterSchema.safeParse({ + field: 'created_at', type: 'date', defaultValue: { preset: 'last_7_days' }, + }).success).toBe(false); + }); + + it('keeps `optionsFrom.labelField` optional', () => { + expect(GlobalFilterSchema.safeParse({ + field: 'owner', optionsFrom: { object: 'users', valueField: 'id' }, + }).success).toBe(true); + expect(SpecGlobalFilterSchema.safeParse({ + field: 'owner', optionsFrom: { object: 'users', valueField: 'id' }, + }).success).toBe(false); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// DashboardWidgetSchema +// ───────────────────────────────────────────────────────────────────────────── + +describe('DashboardWidgetSchema derives from the spec', () => { + const specKeys = Object.keys(shapeOf(SpecDashboardWidgetSchema)).sort(); + const localKeys = Object.keys(shapeOf(DashboardWidgetSchema)).sort(); + + it('carries every spec key', () => { + for (const key of specKeys) { + expect(localKeys, `spec key '${key}' missing locally`).toContain(key); + } + }); + + it('extends the spec by exactly the documented objectui-only key', () => { + // If the spec ever claims `component`, the two meanings must be reconciled + // rather than silently shadowed. + expect(localKeys.filter((k) => !specKeys.includes(k))).toEqual(['component']); + }); + + it('stops stripping the twelve spec keys the hand copy dropped', () => { + // Every one of these survived `objectui validate` with its value deleted + // before the derivation — including the capability gates, which the + // dashboard renderer honours at runtime. + const parsed = DashboardWidgetSchema.parse({ + id: 'w1', + type: 'bar', + description: 'Pipeline by stage', + colorVariant: 'blue', + requiresObject: 'opportunity', + requiresService: 'analytics', + actionUrl: '/opportunities', + actionType: 'url', + actionIcon: 'chart', + suppressWarnings: ['no-data'], + aria: { ariaLabel: 'Pipeline' }, + dataset: 'pipeline', + dimensions: ['stage'], + values: ['amount'], + layout: { x: 0, y: 0, w: 6, h: 4 }, + filterBindings: { dateRange: 'closed_at' }, + }); + expect(parsed.description).toBe('Pipeline by stage'); + expect(parsed.colorVariant).toBe('blue'); + expect(parsed.requiresObject).toBe('opportunity'); + expect(parsed.requiresService).toBe('analytics'); + expect(parsed.actionUrl).toBe('/opportunities'); + expect(parsed.actionType).toBe('url'); + expect(parsed.actionIcon).toBe('chart'); + expect(parsed.suppressWarnings).toEqual(['no-data']); + expect(parsed.aria).toEqual({ ariaLabel: 'Pipeline' }); + expect(parsed.layout).toEqual({ x: 0, y: 0, w: 6, h: 4 }); + expect(parsed.filterBindings).toEqual({ dateRange: 'closed_at' }); + }); +}); + +describe('DashboardWidgetSchema pinned divergences', () => { + it('keeps `id` optional — stored dashboards and legacy widgets omit it', () => { + expect(DashboardWidgetSchema.safeParse({ type: 'metric' }).success).toBe(true); + expect(SpecDashboardWidgetSchema.safeParse({ type: 'metric' }).success).toBe(false); + }); + + it('widens `type` for the objectui-only `list` / `custom` families', () => { + for (const type of ['list', 'custom']) { + expect(DashboardWidgetSchema.safeParse({ id: 'w', type }).success).toBe(true); + // If the spec adopts either family, drop the widening and use its enum. + expect(SpecDashboardWidgetSchema.safeParse({ id: 'w', type }).success).toBe(false); + } + }); + + it('still accepts the legacy `component` envelope', () => { + const parsed = DashboardWidgetSchema.parse({ + id: 'w', component: { type: 'chart' }, layout: { x: 0, y: 0, w: 4, h: 3 }, + }); + expect(parsed.component).toEqual({ type: 'chart' }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Rename tripwires — the new names must not be spec names +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Every export name `@objectstack/spec` publishes, per subpath — TYPES included. + * + * A runtime `import()` would only see values, and five of the six names below + * are types, so a value-only check would pass vacuously on exactly the cases + * that matter. This reads the compiler's view of each subpath's `.d.ts`, which + * is the same source `scripts/check-spec-symbol-derivation.mjs` uses. + */ +function specExportNames(subpaths: readonly string[]): Set { + const require = createRequire(import.meta.url); + const pkgPath = require.resolve('@objectstack/spec/package.json'); + const pkgDir = dirname(pkgPath); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { + exports?: Record; + }; + + const files: string[] = []; + for (const [sub, cond] of Object.entries(pkg.exports ?? {})) { + const name = sub === '.' ? '@objectstack/spec' : `@objectstack/spec${sub.slice(1)}`; + if (!subpaths.includes(name)) continue; + const dts = cond?.import?.types ?? cond?.require?.types; + if (dts) files.push(resolve(pkgDir, dts)); + } + if (files.length !== subpaths.length) { + throw new Error(`could not resolve type entrypoints for ${subpaths.join(', ')}`); + } + + const program = ts.createProgram(files, { + noEmit: true, + skipLibCheck: true, + strict: false, + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + }); + const checker = program.getTypeChecker(); + + const names = new Set(); + for (const file of files) { + const sf = program.getSourceFile(file); + const moduleSymbol = sf && checker.getSymbolAtLocation(sf); + if (!moduleSymbol) throw new Error(`no module symbol for ${file}`); + for (const exported of checker.getExportsOfModule(moduleSymbol)) names.add(exported.getName()); + } + return names; +} + +describe('renamed local dialects do not collide with a spec export (objectui#3074 lesson)', () => { + const names = specExportNames(['@objectstack/spec/ui', '@objectstack/spec/data']); + + it('reads a plausible spec export set (guards the assertions below)', () => { + expect(names.size).toBeGreaterThan(100); + // The names these six were renamed OFF are still the spec's — that is why + // the renames happened, and it keeps this test honest about what it reads. + for (const owned of [ + 'ChartSeries', 'ChartSeriesSchema', 'QueryAST', 'QuerySchema', + 'DriverInterface', 'DatasourceSchema', + ]) { + expect(names, `spec no longer owns '${owned}' — re-run the triage`).toContain(owned); + } + }); + + it.each([ + ['ChartDataSeries', 'inline-data series of the objectui ChartSchema node'], + ['ChartDataSeriesSchema', 'zod twin of ChartDataSeries'], + ['SqlQueryAST', 'compiled SQL syntax tree, not the spec ObjectQL request'], + ['DriverQueryConfig', 'high-level query config the SQL AST builder consumes'], + ['SqlDriverInterface', "objectui's SQL-oriented client driver abstraction"], + ['DatasourceRegistration', 'in-memory datasource registration record'], + ])('the spec does not own `%s` (%s)', (name) => { + expect(names).not.toContain(name); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// JoinedReportBlock — inverted pin, NOT burnable here +// ───────────────────────────────────────────────────────────────────────────── + +/** + * `JoinedReportBlock` collides with a spec export that carries no type at all: + * the spec declares `JoinedReportBlockSchema` as `z.ZodTypeAny`, so + * `z.infer` — and therefore the exported `JoinedReportBlock` type — + * resolves to `unknown`. Re-exporting it would replace objectui's precise block + * interface (`name`/`columns`/`groupingsDown`/`groupingsAcross`/`filter`/`chart`) + * with nothing at all: a type-safety regression wearing a burn-down's clothes. + * + * This is the objectstack#4171 failure mode, one variant wider than the three + * already pinned in `spec-derived-unions.test.ts`. Those erase to `any`, which + * the `0 extends (1 & T)` probe detects; this one erases to `unknown`, which + * that probe reports as `false` while being just as empty. Any triage that only + * screens for `any` will conclude this symbol is safely derivable. It is not. + * + * So it stays in the ledger with its local declaration intact. The day the spec + * types the schema properly, `IsUnknown<…>` flips to `false`, `true satisfies + * false` stops compiling, and the failure is the instruction: re-run the triage + * and burn it down. + */ +type IsUnknown = [unknown] extends [T] ? ([T] extends [unknown] ? true : false) : false; +type IsAny = 0 extends 1 & T ? true : false; +const _specJoinedReportBlockIsStillUntyped = true satisfies IsUnknown; +const _specJoinedReportBlockIsNotEvenAny = false satisfies IsAny; +void _specJoinedReportBlockIsStillUntyped; +void _specJoinedReportBlockIsNotEvenAny; + +describe('JoinedReportBlock stays in the ledger (objectstack#4171)', () => { + it('documents why: the spec ships the schema untyped', () => { + // The compile-time pins above are the real guard; this keeps the reason + // visible in the test report rather than only in a comment. + expect(true).toBe(true); + }); +}); diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 7db83ceced..75997110af 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -15,6 +15,7 @@ * @packageDocumentation */ +import type { DashboardWidget as SpecDashboardWidget } from '@objectstack/spec/ui'; import type { BaseSchema, SchemaNode } from './base'; /** @@ -653,97 +654,63 @@ export interface DashboardWidgetLayout { } /** - * Dashboard Widget + * Dashboard Widget — DERIVED from `@objectstack/spec/ui`'s `DashboardWidget` + * (objectstack#4115): every spec key it does not override flows in through the + * `extends` above, so the key set tracks the protocol instead of being restated. * * Supports two formats: * 1. **Component format** (legacy): `{ id, component: { type, ... }, layout }` * 2. **Shorthand format** (@objectstack/spec): `{ type: 'metric'|'bar'|…, options: {…}, layout }` + * + * `Partial<>` because the spec requires `id` while stored objectui dashboards + * (and every widget in the legacy component format) omit it. The `Omit` list is + * the set of keys objectui deliberately re-types, each explained at its + * declaration below; anything not listed there is the spec's. + * + * Zod twin: `zod/complex.zod.ts` `DashboardWidgetSchema`. + * Drift guard: `__tests__/report-chart-query-spec-parity.test.ts`. */ -export interface DashboardWidgetSchema { - id?: string; - title?: string; - /** Widget description */ - description?: string; - /** Component schema (legacy format) */ +export interface DashboardWidgetSchema + extends Omit, 'type' | 'options' | 'chartConfig' | 'filter' | 'responsive'> { + // `id`, `title`, `description`, `colorVariant`, `actionUrl`, `actionType`, + // `actionIcon`, `dataset`, `dimensions`, `values`, `filterBindings`, + // `requiresObject`, `requiresService`, `compareTo`, `aria`, … all flow in from + // the spec through the `extends` above — do not restate them here. + /** Component schema (legacy format) — objectui-only, no spec counterpart. */ component?: SchemaNode; layout?: DashboardWidgetLayout; - /** Widget visualization type (spec shorthand format) */ + /** + * Widget visualization type (spec shorthand format). + * Widened off the spec's 19-family enum: objectui's `DASHBOARD_WIDGET_TYPES` + * also carries `list` and `custom`, which the spec does not model. + */ type?: string; - /** Widget-specific configuration (spec shorthand format) */ + /** Widget-specific configuration (spec shorthand format). Kept `unknown` — objectui + * renderers pass widget-family-specific bags the spec's `options` object does not model. */ options?: unknown; /** Chart configuration for chart-type widgets */ chartConfig?: any; /** - * Widget color variant. - * Aligned with @objectstack/spec WidgetColorVariantSchema. - */ - colorVariant?: 'default' | 'blue' | 'teal' | 'orange' | 'purple' | 'success' | 'warning' | 'danger'; - /** Action URL for clickable widgets */ - actionUrl?: string; - /** Action type for widget interactions */ - actionType?: string; - /** Action icon name */ - actionIcon?: string; - /** - * ADR-0021 — the semantic-layer `dataset` this widget binds to. The widget - * selects the dataset's dimensions/measures BY NAME; the dataset owns the - * base object, allowed joins, intrinsic filter, dimensions, and measures, so - * numbers stay consistent across every surface. This is the single - * author-facing analytics shape and the only one Studio emits. - * Aligned with @objectstack/spec DashboardWidgetSchema.dataset. - */ - dataset?: string; - /** - * Dimension names (from the bound `dataset`) for X / group / split. - * Aligned with @objectstack/spec DashboardWidgetSchema.dimensions. - */ - dimensions?: string[]; - /** - * Measure names (from the bound `dataset`) for the value axis (≥1). - * Aligned with @objectstack/spec DashboardWidgetSchema.values. - */ - values?: string[]; - /** - * Data binding: Filter conditions. - * Aligned with @objectstack/spec DashboardWidgetSchema.filter. + * Data binding: filter conditions. Kept `any` — objectui passes an ObjectQL + * FilterNode array here, not the spec's `FilterCondition` envelope. */ filter?: any; /** - * Responsive configuration per breakpoint. - * Aligned with @objectstack/spec DashboardWidgetSchema.responsive. + * Responsive configuration per breakpoint. Kept `any` — the renderer reads a + * per-breakpoint record, which the spec's single `responsive` object does not + * model; converging the two is deferred. */ responsive?: any; /** - * Enable search input for table-type widgets. + * Enable search input for table-type widgets. objectui-only — no spec counterpart. * @default false */ searchable?: boolean; /** - * Enable pagination for table-type widgets. + * Enable pagination for table-type widgets. objectui-only — no spec counterpart. * @default false */ pagination?: boolean; - /** - * Per-widget bindings from a dashboard-level filter (referenced by its - * `name`, or the reserved name `"dateRange"` for the built-in date range) - * to one of THIS widget's fields: - * - string → apply the filter to that field (e.g. `{ dateRange: 'signed_at' }`) - * - false → opt this widget out of that filter - * - absent → default binding: the filter's own `field` - * (dateRange: `dateRange.field ?? 'created_at'`) - * Aligned with @objectstack/spec DashboardWidgetSchema.filterBindings - * (framework#2501). - */ - filterBindings?: Record; - /** - * ARIA accessibility attributes. - * Aligned with @objectstack/spec AriaPropsSchema. - */ - aria?: { - ariaLabel?: string; - ariaDescribedBy?: string; - role?: string; - }; } /** diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 48d838372a..1d99cdc922 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -755,9 +755,19 @@ export interface TreeViewSchema extends BaseSchema { export type ChartType = SpecChartType; /** - * Chart data series + * One inline-data series of the objectui `ChartSchema` node — a display name + * plus the literal numbers to plot, positionally aligned with the chart's + * `categories`. + * + * Renamed off `ChartSeries` (objectstack#4115): `@objectstack/spec/ui` owns that + * name for a **dataset-bound series descriptor** — `{ name, label?, type?, + * stack?, yAxis, variant?, dashArray?, opacity? }`, where `name` identifies a + * MEASURE and the values come from the query, so it carries no `data` at all. + * The two are not mutually assignable in either direction. `@object-ui/plugin-charts` + * talks to the spec's shape (`ChartSeries.type` per-series family overrides, + * `ChartSeries.stack`); this one belongs to the static SDUI chart node. */ -export interface ChartSeries { +export interface ChartDataSeries { /** * Series name */ @@ -796,7 +806,7 @@ export interface ChartSchema extends BaseSchema { /** * Data series */ - series: ChartSeries[]; + series: ChartDataSeries[]; /** * Chart height */ diff --git a/packages/types/src/data-protocol.ts b/packages/types/src/data-protocol.ts index 2766f11900..5cb10e6129 100644 --- a/packages/types/src/data-protocol.ts +++ b/packages/types/src/data-protocol.ts @@ -9,8 +9,8 @@ /** * @object-ui/types - Data Protocol Advanced Types * - * Phase 3: Complete implementation of QuerySchema, FilterSchema, - * ValidationSchema, DriverInterface, and DatasourceSchema. + * Phase 3: Complete implementation of DriverQueryConfig, FilterSchema, + * ValidationSchema, SqlDriverInterface, and DatasourceRegistration. * * @module data-protocol * @packageDocumentation @@ -38,7 +38,7 @@ import type { /** * ============================================================================= - * Phase 3.3: QuerySchema AST Implementation + * Phase 3.3: DriverQueryConfig AST Implementation * ============================================================================= */ @@ -154,7 +154,7 @@ export interface OffsetNode extends QueryASTNode { */ export interface SubqueryNode extends QueryASTNode { type: 'subquery'; - query: QueryAST; + query: SqlQueryAST; alias?: string; } @@ -286,9 +286,17 @@ export type ComparisonOperator = export type LogicalOperator = 'and' | 'or' | 'not'; /** - * Complete Query AST (Phase 3.3.1) - */ -export interface QueryAST { + * Complete SQL query AST (Phase 3.3.1). + * + * Renamed off `QueryAST` (objectstack#4115): `@objectstack/spec/data` owns that + * name for the **ObjectQL query descriptor** (`{ object, fields, where, orderBy, + * expand, … }`) — a declarative request against an object. This is the compiled + * **SQL syntax tree** (`select`/`from`/`join`/`where`/`group_by`/…) that + * `@object-ui/core`'s `QueryASTBuilder` produces from {@link DriverQueryConfig}; + * the two are not mutually assignable in either direction. Import the spec's + * `QueryAST` when you mean the request, this one when you mean the tree. + */ +export interface SqlQueryAST { select: SelectNode; from: FromNode; joins?: JoinNode[]; @@ -300,9 +308,16 @@ export interface QueryAST { } /** - * Query Schema - High-level query configuration + * High-level query configuration — the input `QueryASTBuilder` compiles into a + * {@link SqlQueryAST}, and the shape drivers receive on `find()`. + * + * Renamed off `QuerySchema` (objectstack#4115): `@objectstack/spec/data` exports + * `QuerySchema` as the **zod schema value** for its ObjectQL `QueryAST`, so the + * name promised a spec artifact while delivering an objectui-local TS interface + * with a different key set (`filter`/`sort`/`joins`/`aggregations` vs the spec's + * `where`/`orderBy`/`expand`). */ -export interface QuerySchema { +export interface DriverQueryConfig { /** * Target object/table */ @@ -1038,14 +1053,22 @@ export type ObjectValidationRule = /** * ============================================================================= - * Phase 3.6: DriverInterface - Database Driver Abstraction + * Phase 3.6: SqlDriverInterface - Database Driver Abstraction * ============================================================================= */ /** - * Database Driver Interface (Phase 3.6) + * Database driver abstraction (Phase 3.6). + * + * Renamed off `DriverInterface` (objectstack#4115): `@objectstack/spec/data` + * owns that name for the platform's **runtime driver contract** + * (`supports`/`execute`/`findStream`/pool stats, and `find()` taking an ObjectQL + * `SqlQueryAST`). This is objectui's own SQL-oriented client abstraction — + * `query(sql, params)`, `executeAST()`, `batch()` — and the two interfaces are + * not mutually assignable. Import the spec's `DriverInterface` when you mean the + * platform contract. */ -export interface DriverInterface { +export interface SqlDriverInterface { /** * Driver name */ @@ -1074,12 +1097,12 @@ export interface DriverInterface { /** * Execute query from AST */ - executeAST(ast: QueryAST): Promise>; + executeAST(ast: SqlQueryAST): Promise>; /** * Find records */ - find(table: string, query: QuerySchema): Promise>; + find(table: string, query: DriverQueryConfig): Promise>; /** * Find one record @@ -1394,14 +1417,21 @@ export interface ConnectionPool { /** * ============================================================================= - * Phase 3.7: DatasourceSchema - Multi-Datasource Management + * Phase 3.7: DatasourceRegistration - Multi-Datasource Management * ============================================================================= */ /** - * Datasource Schema (Phase 3.7) + * A datasource as registered with {@link DatasourceManager} at runtime (Phase 3.7). + * + * Renamed off `DatasourceSchema` (objectstack#4115): `@objectstack/spec/data` + * exports `DatasourceSchema` as the **authored datasource metadata document** + * (`driver` is a driver NAME, `config` a record, plus `pool`/`ssl`/`retryPolicy`/ + * `capabilities`/`schemaMode`). This is the in-memory registration record — its + * `driver` is a live {@link SqlDriverInterface} instance and its connection lives + * under `connection`, so the two never describe the same value. */ -export interface DatasourceSchema { +export interface DatasourceRegistration { /** * Datasource name */ @@ -1425,7 +1455,7 @@ export interface DatasourceSchema { /** * Driver interface */ - driver?: DriverInterface; + driver?: SqlDriverInterface; /** * Whether datasource is default @@ -1571,7 +1601,7 @@ export interface DatasourceManager { /** * Register a datasource */ - register(datasource: DatasourceSchema): void; + register(datasource: DatasourceRegistration): void; /** * Unregister a datasource @@ -1581,12 +1611,12 @@ export interface DatasourceManager { /** * Get datasource by name */ - get(name: string): DatasourceSchema | undefined; + get(name: string): DatasourceRegistration | undefined; /** * Get default datasource */ - getDefault(): DatasourceSchema | undefined; + getDefault(): DatasourceRegistration | undefined; /** * Switch active datasource (Phase 3.7.3) @@ -1596,12 +1626,12 @@ export interface DatasourceManager { /** * Get active datasource */ - getActive(): DatasourceSchema | undefined; + getActive(): DatasourceRegistration | undefined; /** * List all datasources */ - list(): DatasourceSchema[]; + list(): DatasourceRegistration[]; /** * Check datasource health (Phase 3.7.4) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d5d9256136..53aed8af82 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -164,7 +164,7 @@ export type { TreeNode, TreeViewSchema, ChartType, - ChartSeries, + ChartDataSeries, ChartSchema, PivotAggregation, PivotTableSchema, @@ -476,8 +476,8 @@ export type { FunctionNode, ComparisonOperator, LogicalOperator, - QueryAST, - QuerySchema, + SqlQueryAST, + DriverQueryConfig, QuerySortConfig, JoinConfig, AggregationConfig, @@ -513,7 +513,7 @@ export type { RangeValidation, ObjectValidationRule, // Driver Interface (Phase 3.6) - DriverInterface, + SqlDriverInterface, ConnectionConfig, DriverQueryResult, BatchOperation, @@ -522,7 +522,7 @@ export type { CacheManager, ConnectionPool, // Datasource Schema (Phase 3.7) - DatasourceSchema, + DatasourceRegistration, DatasourceType, DatasourceMetric, DatasourceAlert, diff --git a/packages/types/src/zod/app.zod.ts b/packages/types/src/zod/app.zod.ts index 34d02a3b88..e4fb9a9854 100644 --- a/packages/types/src/zod/app.zod.ts +++ b/packages/types/src/zod/app.zod.ts @@ -17,7 +17,10 @@ */ import { z } from 'zod'; -import { AppSchema as SpecAppSchema } from '@objectstack/spec/ui'; +import { + AppSchema as SpecAppSchema, + AppContextSelectorSchema as SpecAppContextSelectorSchema, +} from '@objectstack/spec/ui'; import { BaseSchema, specFieldsExcept } from './base.zod.js'; // ============================================================================ @@ -164,28 +167,27 @@ export const AppActionSchema = z.object({ // ============================================================================ /** - * App Context Selector Schema — sidebar/topbar scope dropdown whose - * selected value is injected into nav items as a `{}` template var. - * Mirrors `@objectstack/spec` `AppContextSelectorSchema`. + * App Context Selector Schema — sidebar/topbar scope dropdown whose selected + * value is injected into nav items as a `{}` template var. + * + * DERIVED from `@objectstack/spec/ui` (objectstack#4115): every spec key + * (`id`/`icon`/`optionsSource`/`includeAll`/`allValue`/`persist`/`placement`, + * including its defaults) flows in **by reference**, so a key the spec adds or + * retypes cannot silently diverge here. Before this derivation the local hand + * copy was a full restatement carrying the spec's own symbol name. + * + * One pinned divergence, kept deliberately: + * - `label` is widened to accept objectui's i18n label envelope + * (`{ default, translations }` / any record) as well as the spec's plain + * string. `AppContextSelectors` (@object-ui/app-shell) renders it through + * `resolveI18nLabel`, so narrowing to the spec's `z.string()` would reject + * localized selectors the renderer already supports. + * + * Drift guard: `__tests__/report-chart-query-spec-parity.test.ts`. */ -export const AppContextSelectorSchema = z.object({ - id: z.string().describe('Selector id; value exposed as nav template var {}'), - label: z.union([z.string(), z.record(z.string(), z.any())]).describe('Dropdown label'), - icon: z.string().optional().describe('Icon name (Lucide)'), - optionsSource: z.object({ - endpoint: z.string().describe('REST endpoint returning option rows'), - valueKey: z.string().optional().default('id'), - labelKey: z.string().optional().default('name'), - filter: z.array(z.object({ - key: z.string(), - op: z.enum(['eq', 'ne', 'in', 'nin']).optional().default('eq'), - value: z.union([z.string(), z.array(z.string())]), - })).optional().describe('Predicates (AND) each option row must satisfy'), - }).describe('Option data source'), - includeAll: z.boolean().optional().default(true), - allValue: z.string().optional().default(''), - persist: z.enum(['query', 'session', 'none']).optional().default('query'), - placement: z.enum(['sidebar_header', 'topbar']).optional().default('sidebar_header'), +export const AppContextSelectorSchema = SpecAppContextSelectorSchema.extend({ + label: z.union([z.string(), z.record(z.string(), z.any())]) + .describe('Dropdown label — plain string or objectui i18n label envelope'), }); /** diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index d3a19eddee..15ca470ffd 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -17,7 +17,11 @@ */ import { z } from 'zod'; -import { DashboardSchema as SpecDashboardSchema } from '@objectstack/spec/ui'; +import { + DashboardSchema as SpecDashboardSchema, + DashboardWidgetSchema as SpecDashboardWidgetSchema, + GlobalFilterSchema as SpecGlobalFilterSchema, +} from '@objectstack/spec/ui'; import { BaseSchema, SchemaNodeSchema, specFieldsExcept } from './base.zod.js'; import { DASHBOARD_COLOR_VARIANTS, DASHBOARD_WIDGET_TYPES } from '../designer.js'; @@ -272,36 +276,59 @@ export const DashboardWidgetLayoutSchema = z.object({ }); /** - * Dashboard Widget Schema + * Dashboard Widget Schema — DERIVED from `@objectstack/spec/ui` + * (objectstack#4115): every spec key flows in **by reference** via + * {@link specFieldsExcept}, so a key the spec adds or retypes cannot silently + * diverge here. * - * Supports two formats: - * 1. Component format (legacy): `{ id, component: { type, ... }, layout }` - * 2. Shorthand format (@objectstack/spec): `{ type: 'metric'|'bar'|…, options: {…}, layout }` + * The hand copy this replaces declared 10 of the spec's 22 keys, and because a + * `z.object()` strips unknown keys, the other 12 were dropped without a word by + * `objectui validate` — including `chartConfig`, `colorVariant`, `filter`, + * `responsive`, `aria`, `actionUrl`/`actionType`/`actionIcon`, `compareTo` and + * the `requiresObject`/`requiresService` capability gates. The TS interface in + * `complex.ts` declared most of them all along, so a widget could type-check in + * objectui and still lose half its configuration on validation. + * + * Two pinned divergences plus one objectui-only extension: + * - `id` relaxed to optional — the spec requires it, but stored objectui + * dashboards (and the legacy `component` format below) omit it. + * - `type` widened to `z.string()` — objectui's `DASHBOARD_WIDGET_TYPES` also + * carries `list` and `custom`, which the spec's 19-family visualization enum + * does not model. Narrowing here would reject widgets the designer emits. + * - `component` — the legacy `{ id, component: , layout }` envelope, + * which the spec has no room for. Migration to the shorthand form is deferred. + * + * Drift guard: `__tests__/report-chart-query-spec-parity.test.ts`. */ -export const DashboardWidgetSchema = z.object({ +export const DashboardWidgetSchema = specFieldsExcept(SpecDashboardWidgetSchema.shape, [ + 'id', + 'type', +] as const).extend({ id: z.string().optional().describe('Widget ID'), - title: z.string().optional().describe('Widget Title'), + type: z.string().optional().describe('Widget visualization type (spec shorthand; widened for `list`/`custom`)'), component: SchemaNodeSchema.optional().describe('Widget Component (legacy format)'), - layout: DashboardWidgetLayoutSchema.optional().describe('Widget Layout'), - type: z.string().optional().describe('Widget visualization type (spec shorthand)'), - options: z.unknown().optional().describe('Widget specific configuration (spec shorthand)'), - // ADR-0021 semantic-layer binding — the single author-facing analytics shape. - dataset: z.string().optional().describe('Dataset name to bind (ADR-0021)'), - dimensions: z.array(z.string()).optional().describe('Dimension names — X/group/split'), - values: z.array(z.string()).optional().describe('Measure names — Y (≥1 when dataset-bound)'), - filterBindings: z.record(z.string(), z.union([z.string(), z.literal(false)])).optional() - .describe('Per-widget dashboard-filter bindings: filter name → this widget\'s field, or false to opt out'), }); /** - * Global Filter Schema — a dashboard-level filter definition. - * Aligned with @objectstack/spec GlobalFilterSchema (framework#2501: `name`). + * Global Filter Schema — a dashboard-level filter definition, DERIVED from + * `@objectstack/spec/ui` (objectstack#4115): `name`, `field`, `label`, `type`, + * `scope` and `targetWidgets` flow in **by reference**. + * + * Three pinned divergences, each backed by a runtime normalizer in + * `@object-ui/core`'s `dashboard-filters.ts`: + * - `options` also accepts the bare-string shorthand (`options: ['EMEA', …]`) + * and an object without `label`; `normalizeFilterOptions` folds both into the + * spec's `{ value, label }` form before anything renders them. + * - `optionsFrom.labelField` stays optional (it falls back to `valueField`) and + * `filter` stays `z.any()` — objectui passes an ObjectQL FilterNode array + * here, not the spec's `FilterCondition` envelope. + * - `defaultValue` stays `z.any()` — `normalizeDateDefault` (framework#4475) + * lifts a date preset NAME into `{ preset }`, and stored dashboards carry + * that object form, which the spec's `string | number | boolean` rejects. + * + * Drift guard: `__tests__/report-chart-query-spec-parity.test.ts`. */ -export const GlobalFilterSchema = z.object({ - name: z.string().optional().describe('Stable filter name (variable key); defaults to field'), - field: z.string().describe('Default target field'), - label: z.string().optional().describe('Display label'), - type: z.enum(['text', 'select', 'date', 'number', 'lookup']).optional().describe('Filter control type'), +export const GlobalFilterSchema = SpecGlobalFilterSchema.extend({ options: z.array(z.union([ z.string(), z.object({ @@ -315,9 +342,7 @@ export const GlobalFilterSchema = z.object({ labelField: z.string().optional(), filter: z.any().optional(), }).optional().describe('Dynamic option source'), - defaultValue: z.any().optional().describe('Initial value'), - scope: z.string().optional().describe('Filter scope'), - targetWidgets: z.array(z.string()).optional().describe('Widget-id allow-list'), + defaultValue: z.any().optional().describe('Initial value (objectui also accepts the normalized date-preset object)'), }); /** diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index f6fad672cf..173220cdc8 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -217,9 +217,14 @@ export const TreeViewSchema = BaseSchema.extend({ export const ChartTypeSchema = SpecChartTypeSchema; /** - * Chart Series Schema + * Zod twin of {@link ChartDataSeries} — the objectui chart node's inline-data + * series. Renamed off `ChartSeriesSchema` (objectstack#4115) for the same reason + * the TS type was: `@objectstack/spec/ui`'s `ChartSeriesSchema` describes a + * dataset-bound series (no `data`, plus `type`/`stack`/`yAxis`/`variant`), so a + * consumer importing `ChartSeriesSchema` from `@object-ui/types` could not tell + * which contract they had. */ -export const ChartSeriesSchema = z.object({ +export const ChartDataSeriesSchema = z.object({ name: z.string().describe('Series name'), data: z.array(z.number()).describe('Series data points'), color: z.string().optional().describe('Series color'), @@ -234,7 +239,7 @@ export const ChartSchema = BaseSchema.extend({ title: z.string().optional().describe('Chart title'), description: z.string().optional().describe('Chart description'), categories: z.array(z.string()).optional().describe('X-axis categories'), - series: z.array(ChartSeriesSchema).describe('Chart data series'), + series: z.array(ChartDataSeriesSchema).describe('Chart data series'), height: z.union([z.string(), z.number()]).optional().describe('Chart height'), width: z.union([z.string(), z.number()]).optional().describe('Chart width'), showLegend: z.boolean().optional().describe('Show legend'), diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index 39897552d3..4026c0eb45 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -138,7 +138,7 @@ export { TreeNodeSchema, TreeViewSchema, ChartTypeSchema, - ChartSeriesSchema, + ChartDataSeriesSchema, ChartSchema, TimelineEventSchema, TimelineSchema, diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 11a234c803..abdd13010c 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -32,7 +32,7 @@ import { UserActionsConfigSchema as SpecUserActionsConfigSchema, AriaPropsSchema as SpecAriaPropsSchema, } from '@objectstack/spec/ui'; -import { BaseSchema } from './base.zod.js'; +import { BaseSchema, specFieldsExcept } from './base.zod.js'; /** * HTTP Method Schema — `@objectstack/spec/ui` schema re-exported by reference @@ -269,7 +269,7 @@ const UserFiltersSchema = z.object({ /** * ListView Schema — derived from `@objectstack/spec/ui` `ListViewSchema` (issue #2231). * - * Spec-owned fields flow in **by reference** (see `SpecListViewFields`) so they auto-track + * Spec-owned fields flow in **by reference** (see the `.extend()` on the declaration) so they auto-track * the protocol instead of being re-typed here; the drift-guard test * (`__tests__/list-view-spec-parity.test.ts`) fails if the spec grows a field objectui * has not triaged. objectui-only / legacy fields are declared locally on top via @@ -287,28 +287,26 @@ const UserFiltersSchema = z.object({ * Migrating the remaining legacy vocabulary to the spec-canonical keys (`type`/`columns`/ * `filter`/`userActions`) is deferred — see #2231. */ -// Spec view-config fields, minus: the component envelope (name/label/description → -// BaseSchema), the discriminator/renamed/relaxed keys (type/columns), and the configs -// kept as local overrides below. `.partial()` guarantees no *future* spec field can -// become required and silently invalidate existing objectui payloads. -const SpecListViewFields = SpecListViewSchema - .omit({ - type: true, - columns: true, - name: true, - label: true, - description: true, - userFilters: true, - userActions: true, - aria: true, - conditionalFormatting: true, - exportOptions: true, - kanban: true, - calendar: true, - gallery: true, - timeline: true, - }) - .partial(); +// Spec view-config keys objectui overrides locally: the component envelope +// (name/label/description → BaseSchema), the discriminator/renamed/relaxed keys +// (type/columns), and the configs redeclared below. EVERY other spec key flows +// in by reference at the declaration — see `SPEC_FIELDS` there. +const LIST_VIEW_LOCAL_OVERRIDES = [ + 'type', + 'columns', + 'name', + 'label', + 'description', + 'userFilters', + 'userActions', + 'aria', + 'conditionalFormatting', + 'exportOptions', + 'kanban', + 'calendar', + 'gallery', + 'timeline', +] as const; // ── Per-view-type configs, derived from spec (issue #2231) ──────────────────── // Each is the spec config `.partial()`-ed: spec requires `columns`/`titleField`/ @@ -316,11 +314,11 @@ const SpecListViewFields = SpecListViewSchema // product's own CreateViewDialog emits `kanban: { groupByField }` alone), so // requiring them would reject views the app itself creates. `.partial()` keeps the // spec's field set and types by reference while staying permissive — the same -// trade-off `SpecListViewFields` makes above. +// trade-off the spec-field import on `ListViewSchema` makes. // // `gantt` needs no local schema at all: the spec config already covers every field // the renderer reads and is `.passthrough()` for renderer-ahead knobs, so it flows -// in with the rest of `SpecListViewFields`. +// in with the rest of the imported spec fields. // // The deprecated aliases below are the pre-#2231 objectui vocabulary. They stay // accepted so stored view metadata keeps validating, but the spec key is canonical @@ -382,13 +380,21 @@ export const UserActionsSchema = SpecUserActionsConfigSchema.extend({ }); export const ListViewSchema = BaseSchema - // Import spec-owned fields by reference: data, filter, sort, searchableFields, + // Spec-owned fields by reference. `specFieldsExcept` reads the spec object's + // `.shape` rather than calling `.omit()`, which zod 4 refuses on a schema + // carrying a refinement (objectui#3063); `.partial()` inside it guarantees no + // *future* spec field can become required and silently invalidate stored + // objectui payloads. The spec binding sits in this initializer on purpose — + // that is what makes the derivation visible to + // `scripts/check-spec-symbol-derivation.mjs` instead of hidden one hop away. + // + // Imported here: data, filter, sort, searchableFields, // filterableFields, resizable, striped, bordered, compactToolbar, selection, navigation, // pagination, chart, tree, rowHeight, grouping, rowColor, hiddenFields, fieldOrder, // rowActions, bulkActions, bulkActionDefs, virtualScroll, inlineEdit, userActions, // appearance, tabs, addRecord, showRecordCount, allowPrinting, emptyState, responsive, // performance. - .extend(SpecListViewFields.shape) + .extend(specFieldsExcept(SpecListViewSchema.shape, LIST_VIEW_LOCAL_OVERRIDES).shape) .extend({ // Component discriminator — load-bearing for the ObjectQLComponentSchema union. type: z.literal('list-view'), @@ -482,7 +488,7 @@ export const ListViewSchema = BaseSchema }), ]).optional().describe('Export options'), // Per-view-type configs — spec-derived (see the definitions above #2231). - // `gantt` is NOT here: it flows in from `SpecListViewFields` unmodified. + // `gantt` is NOT here: it flows in from the spec fields unmodified. kanban: KanbanConfig.optional().describe('Kanban-specific configuration'), calendar: CalendarConfig.optional().describe('Calendar-specific configuration'), gallery: GalleryConfig.optional().describe('Gallery-specific configuration'), diff --git a/scripts/check-spec-symbol-derivation.mjs b/scripts/check-spec-symbol-derivation.mjs index b133d5b4a1..6ef614ca2c 100644 --- a/scripts/check-spec-symbol-derivation.mjs +++ b/scripts/check-spec-symbol-derivation.mjs @@ -111,6 +111,21 @@ const ALLOW = { "adds a key, retires one, claims an extension name, or widens `value` itself.", issue: 4115, }, + "@object-ui/types:ListViewSchema": { + reason: + "TS twin of the spec-derived `ListViewSchema` zod node (objectql.zod.ts), which DOES " + + "import the spec's fields by reference at its declaration. This alias cannot carry that " + + "reference structurally: the spec's `ListViewSchema` is a zod VALUE, so there is no spec " + + "TYPE to alias or extend, and the objectui node additionally intersects " + + "`ListViewRuntimeProps` — callbacks and an imperative refresh trigger that are not " + + "serialisable view metadata and therefore cannot exist in any schema. Divergence from " + + "the spec is bounded by the zod derivation, not by this declaration; the drift guard is " + + "packages/types/src/__tests__/list-view-spec-parity.test.ts, which fails when the spec " + + "grows an untriaged field, retires one objectui aliases (`type`→`viewType`, relaxed " + + "`columns`, `filter` alongside legacy `filters`), or someone adds a local key outside " + + "the sanctioned set.", + issue: 4115, + }, "@object-ui/types:SelectOption": { reason: "TS twin of the SelectOptionSchema dialect (objectui#3090): carries every spec key " + @@ -159,7 +174,15 @@ const ALLOW = { // cannot be burned down here at all; the fix belongs upstream in the spec, // filed as objectstack#4171. `spec-derived-unions.test.ts` carries an // inverted pin that fails the day the spec types one of them properly. -// Detect: the same `0 extends (1 & Spec)` probe. +// Detect: the same `0 extends (1 & Spec)` probe — but see the variant +// below, which that probe does NOT catch. +// 2b. The SPEC export resolves to `unknown` (`JoinedReportBlock`, whose +// `JoinedReportBlockSchema` the spec declares as `z.ZodTypeAny`). Just as +// empty as case 2 and just as unburnable, but the `any` probe reports +// `false` for it, so a triage that only screens for `any` waves it through +// as "safely derivable". Detect: `[unknown] extends [Spec]`. Pinned in +// packages/types/src/__tests__/report-chart-query-spec-parity.test.ts +// (objectui#3155); also filed under objectstack#4171. // 3. The local declaration carries `[key: string]: any` (`FormField`, // `AppSchema`, `PageSchema`, `ThemeSchema`, …) — the objectstack#4075 // mechanism. An index signature absorbs any extra member, so the two types @@ -175,22 +198,14 @@ const DEBT_ISSUE = 4115; const DEBT = { "@object-ui/types": [ "ActionParam", - "AppContextSelectorSchema", - "ChartSeries", - "ChartSeriesSchema", "CreateExportJobRequest", "CreateExportJobResult", - "DashboardWidgetSchema", - "DatasourceSchema", - "DriverInterface", "FileMetadata", "GestureConfig", "GestureType", - "GlobalFilterSchema", "ImportRowResult", "JoinNode", "JoinedReportBlock", - "ListViewSchema", "NavigationArea", "NavigationAreaSchema", "NavigationItem", @@ -198,8 +213,6 @@ const DEBT = { "OfflineConfig", "PageRegion", "PageRegionSchema", - "QueryAST", - "QuerySchema", "ResponsiveConfig", "Theme", "WidgetManifest",