From d72ac48631e191f80db5da89019a1d1c1daf8a9e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 14:16:01 +0000 Subject: [PATCH] refactor(app-shell): stop declaring 28 symbols under names the spec owns Batch 3 of the objectstack#4115 debt burn-down (objectui#3157), and the ledger's largest single package. Twenty-eight app-shell symbols were declared under names `@objectstack/spec` already exports. Twenty are now imported or derived from the spec; eight are renamed because they model something the spec's same-named export does not. The comment justifying the largest cluster -- "kept local so app-shell does not take a build dependency on the framework spec package" -- was already false: `@objectstack/spec` is a direct dependency of this package. Three live defects the copies were hiding, each fixed by importing the type: * `SchemaDiffEntryKind` was missing `index_mismatch` / `unmapped_index` (framework#3728). ValidationPanel labels diffs from a total map, so an index divergence the server already emits arrived unlabelled. Widening the union made the compiler demand the two missing labels. * `ExplainLayer.contributors[].state` ('active' | 'expired') was absent, so an EXPIRED position / permission-set contribution rendered as a live one. * Several keys were optional locally and required in the spec (`ExternalColumn.primaryKey`, `ExplainRecordAttribution.rules`, `ExplainDecision.principal.positions` / `.permissionSets`), leaving nullish branches that can never fire. Renamed, with what the spec's same-named export actually is: FieldInput -> ScreenFieldInput (an object FIELD's authoring shape) ConversationSummary -> ConversationListItem (the AI context-compaction record) RuntimeConfig -> AppShellRuntimeConfig (the ENGINE runtime config) PageHeaderProps -> PageHeaderComponentProps (the authored SDUI header schema) FlowNode / FlowEdge -> FlowDesignerNode / ...Edge (a COMPLETE authored node/edge) PackageManifest -> PackageManifestRow (the full authored manifest) InstalledPackage -> InstalledPackageRow (the full install record) `FieldGroup` becomes `ObjectFieldGroup` -- the spec's own name for this exact shape -- and is derived from `z.input`, not the exported `z.infer` type: `collapse` carries `.default('none')`, so it is optional to author and required after parsing, and this designer authors. Two symbols are derived structurally with one pinned divergence each: `ScreenSpec` keeps `fields` optional (#3528) and `DecisionOutputDef` adds `required`, which the server enforces but the spec does not model yet. Deriving the latter narrowed `type` from a bare string to the spec's closed enum, so a typo'd picker kind fails to compile rather than degrading to a raw record-id text box (objectui#2955). FlowNode/FlowEdge were NOT derived on purpose: a canvas holds nodes the user has dropped but not finished (no label yet, no edge id yet), so the spec's complete-node type would make the editor's own intermediate state unrepresentable. Two genuine findings there are recorded rather than silently changed, because both alter what gets written to metadata: the designer persists geometry as `ui: {x,y}` while the spec models it twice already (`FlowNode.position`, `FlowCanvasNode`), and the edge inspector can build a `condition` object missing ADR-0089's required `dialect`. Ledger 115 -> 87 collisions; `@object-ui/app-shell` drops out entirely. Mutation-tested in three directions: re-forking a burned symbol trips the guard by name and file, reverting a rename trips it, and leaving a burned name in DEBT trips the rot ratchet. The new parity test also checks `isAggregatedViewContainer` by REFERENCE identity -- the copy it replaced was line-for-line identical, so no value comparison could have caught it (objectui#3003). Refs objectui#3157, objectstack#4115 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015Jdef6ZFhJmiNRhNCd4DW3 --- .changeset/app-shell-spec-symbol-burn-down.md | 52 ++++ .../src/__tests__/spec-symbol-parity.test.ts | 286 ++++++++++++++++++ .../src/console/ai/ConversationsSidebar.tsx | 14 +- .../groupConversationsByDate.test.ts | 10 +- .../src/console/marketplace/marketplaceApi.ts | 17 +- packages/app-shell/src/hooks/index.ts | 2 +- .../src/hooks/useConversationList.ts | 24 +- packages/app-shell/src/index.ts | 2 +- packages/app-shell/src/layout/PageHeader.tsx | 22 +- packages/app-shell/src/layout/index.ts | 2 +- packages/app-shell/src/runtime-config.ts | 23 +- .../src/utils/decisionOutputParams.ts | 33 +- packages/app-shell/src/views/ScreenView.tsx | 86 +++--- .../metadata-admin/AccessExplainPanel.tsx | 113 +++---- .../metadata-admin/EditPackageDialog.test.tsx | 6 +- .../src/views/metadata-admin/PackagesPage.tsx | 57 +++- .../external/ValidationPanel.tsx | 12 + .../src/views/metadata-admin/external/api.ts | 123 +++----- .../inspectors/datasetFilterCondition.ts | 20 +- .../inspectors/flow-nested-selection.test.ts | 4 +- .../metadata-admin/previews/FlowCanvas.tsx | 36 +-- .../previews/ObjectFormCanvas.tsx | 6 +- .../previews/flow-canvas-layout.test.ts | 24 +- .../previews/flow-canvas-layout.ts | 79 ++++- .../previews/flow-canvas-parts.tsx | 4 +- .../metadata-admin/previews/flow-problems.ts | 12 +- .../previews/flow-region-view.tsx | 8 +- .../previews/object-fields-io.ts | 78 +++-- .../metadata-admin/view-item-normalize.ts | 13 +- .../studio-design/StudioDesignSurface.tsx | 8 +- scripts/check-spec-symbol-derivation.mjs | 30 -- 31 files changed, 809 insertions(+), 397 deletions(-) create mode 100644 .changeset/app-shell-spec-symbol-burn-down.md create mode 100644 packages/app-shell/src/__tests__/spec-symbol-parity.test.ts diff --git a/.changeset/app-shell-spec-symbol-burn-down.md b/.changeset/app-shell-spec-symbol-burn-down.md new file mode 100644 index 0000000000..bd9ceab14e --- /dev/null +++ b/.changeset/app-shell-spec-symbol-burn-down.md @@ -0,0 +1,52 @@ +--- +"@object-ui/app-shell": minor +--- + +Stop declaring 28 app-shell symbols under names `@objectstack/spec` owns +(objectui#3157, objectstack#4115 batch 3). + +**Breaking for importers of `@object-ui/app-shell`** — eight exported names +changed, because the spec exports the same name for a *different* thing: + +| was | now | what the spec's same-named export actually is | +|:--|:--|:--| +| `FieldInput` | `ScreenFieldInput` | the authoring shape of an object FIELD | +| `ConversationSummary` | `ConversationListItem` | the AI context-compaction record | +| `RuntimeConfig` | `AppShellRuntimeConfig` | the ENGINE runtime config | +| `PageHeaderProps` | `PageHeaderComponentProps` | the authored SDUI page-header schema | +| `FlowNode` / `FlowEdge` | `FlowDesignerNode` / `FlowDesignerEdge` | a COMPLETE authored flow node/edge | +| `PackageManifest` | `PackageManifestRow` | the full authored package manifest | +| `InstalledPackage` | `InstalledPackageRow` | the full install record | + +The object designer's `FieldGroup` also becomes `ObjectFieldGroup` — that is +the spec's own name for this exact shape, while its `FieldGroup` is the Studio +field-editor's group config. The other nineteen keep their names and are now +imported or derived from the spec instead of re-declared. + +**Three live defects the copies were hiding**, all fixed by importing the real +types: + +- `SchemaDiffEntryKind` was missing `index_mismatch` and `unmapped_index` + (framework#3728). The federation validate panel renders a label per kind from + a total map, so an index divergence — which the server already emits — arrived + as a diff row this UI could not name. The union is now the spec's, and the + compiler required the two missing labels. +- `ExplainLayer.contributors[].state` (`'active' | 'expired'`) did not exist in + the local copy of the access-explain report, so an EXPIRED permission-set or + position contribution rendered identically to a live one. +- `ExternalColumn.primaryKey` was optional locally while the server always sends + it (the spec schema defaults it), and `ExplainRecordAttribution.rules` / + `ExplainDecision.principal.positions` / `.permissionSets` were optional here + and required there — every reader carried a nullish branch that could not fire. + +The comment justifying the largest copy ("kept local so app-shell does not take +a build dependency on the framework spec package") was already false: +`@objectstack/spec` is a direct dependency of this package. + +Two symbols are derived structurally rather than re-exported, each with one +documented divergence pinned by a test: `ScreenSpec` keeps `fields` optional +(an `object-form` step legitimately sends none — #3528), and `DecisionOutputDef` +adds `required`, which the server enforces but the spec does not yet model. +Deriving the latter also narrowed its `type` from a bare `string` to the spec's +closed enum, so a typo'd picker kind now fails to compile instead of silently +degrading to a raw record-id text box (objectui#2955). diff --git a/packages/app-shell/src/__tests__/spec-symbol-parity.test.ts b/packages/app-shell/src/__tests__/spec-symbol-parity.test.ts new file mode 100644 index 0000000000..a89683d4eb --- /dev/null +++ b/packages/app-shell/src/__tests__/spec-symbol-parity.test.ts @@ -0,0 +1,286 @@ +/** + * 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. + */ + +/** + * app-shell ↔ `@objectstack/spec` symbol-collision tripwires + * (objectui#3157, objectstack#4115 burn-down batch 3). + * + * Twenty-eight app-shell symbols used to be declared under names the spec + * already owns. Twenty were burned down by importing or deriving the spec's own + * type (eighteen plain re-exports, plus `ScreenSpec` and `DecisionOutputDef` + * derived structurally with one documented divergence each); eight were renamed + * because they model something the spec's same-named export does not. + * + * One symbol is in both camps: the object designer's `FieldGroup` was renamed to + * `ObjectFieldGroup` AND derived — the spec owns that exact shape, just under + * the other name, while its `FieldGroup` is the Studio field-editor's group + * config. Renaming to the spec's own name was the fix. + * + * A rename only stays a fix for as long as the new name is genuinely free. If + * the spec later ships a `FlowDesignerNode`, this package would quietly be back + * where it started — a local declaration under a spec export's name, read by + * the next agent as the spec's own definition. These tests are that tripwire. + * + * ## Why the spec's names are read through the compiler, not `import * as` + * + * A runtime namespace import sees VALUES only, and almost every symbol in this + * burn-down is a TYPE (`FieldInput`, `RuntimeConfig`, `ConversationSummary`, …). + * A tripwire built on `Object.keys(await import('@objectstack/spec/ui'))` would + * pass for every one of them while proving nothing — the same mistake the + * guard's own header records having made in its first draft. So this reads each + * subpath's `.d.ts` through the TypeScript checker, exactly as + * `scripts/check-spec-symbol-derivation.mjs` does, and gets types and values + * alike. + */ + +import { describe, it, expect } from 'vitest'; +import ts from 'typescript'; +import { createRequire } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; + +import { isAggregatedViewContainer } from '../views/metadata-admin/view-item-normalize'; + +import type { ScreenSpec } from '../views/ScreenView'; +import type { DecisionOutputDef } from '../utils/decisionOutputParams'; +import type { ObjectFieldGroup } from '../views/metadata-admin/previews/object-fields-io'; +import type { + ScreenSpec as SpecScreenSpec, + ScreenFieldSpec as SpecScreenFieldSpec, +} from '@objectstack/spec/contracts'; +import type { DecisionOutputDef as SpecDecisionOutputDef } from '@objectstack/spec/automation'; + +/** Every name `@objectstack/spec` exports from any subpath — types AND values. */ +function specExportNames(): 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 cond of Object.values(pkg.exports ?? {})) { + if (typeof cond !== 'object' || cond === null) continue; + const dts = cond.import?.types ?? cond.require?.types; + if (dts) files.push(resolve(pkgDir, dts)); + } + + 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); + if (!sf) continue; + const moduleSymbol = checker.getSymbolAtLocation(sf); + if (!moduleSymbol) continue; + for (const exported of checker.getExportsOfModule(moduleSymbol)) names.add(exported.getName()); + } + return names; +} + +const SPEC_NAMES = specExportNames(); + +/** + * Sanity: if this set came back empty (bad resolve, changed `exports` map), every + * "the spec does not own X" assertion below would pass vacuously. + */ +describe('the spec export-name probe itself works', () => { + it('reads a non-trivial number of names', () => { + expect(SPEC_NAMES.size).toBeGreaterThan(1000); + }); + + it('sees TYPE-only exports, not just runtime values', () => { + // `FieldInput` is `Omit, 'type'>` — invisible to `import()`. + expect(SPEC_NAMES.has('FieldInput')).toBe(true); + }); +}); + +/** + * The ten renames. Each entry is `[local dialect name, the spec name it used to + * collide with]`, with a one-line note on what the spec's symbol actually means + * — the thing the old name falsely claimed. + */ +const RENAMES: Array<[local: string, formerly: string, specMeaning: string]> = [ + ['ScreenFieldInput', 'FieldInput', "the authoring shape of an object FIELD (Omit, 'type'>)"], + ['ConversationListItem', 'ConversationSummary', 'the AI context-COMPACTION record (keyPoints, tokensSaved, …)'], + ['AppShellRuntimeConfig', 'RuntimeConfig', 'the ENGINE runtime config (engine, engineConfig, resourceLimits)'], + ['PageHeaderComponentProps', 'PageHeaderProps', 'the AUTHORED SDUI page-header node schema (strings, action ids)'], + ['FlowDesignerNode', 'FlowNode', 'a COMPLETE authored flow node (label required)'], + ['FlowDesignerEdge', 'FlowEdge', 'a COMPLETE authored flow edge (id required, condition needs `dialect`)'], + ['PackageManifestRow', 'PackageManifest', 'the full authored package manifest (~40 keys)'], + ['InstalledPackageRow', 'InstalledPackage', 'the full install record (installedAt, upgradeHistory, …)'], +]; + +describe('renamed local dialects do not collide with a spec export', () => { + it.each(RENAMES)('the spec does not own `%s`', (local) => { + expect( + SPEC_NAMES.has(local), + `@objectstack/spec now exports \`${local}\`. This package declares its own ` + + `\`${local}\`, so the rename that fixed objectstack#4115 has re-created the ` + + `collision under the new name. Rename again (and check the new name here ` + + `FIRST — objectui#3074 landed a rename onto another spec export exactly ` + + `this way), or derive from the spec if the two really are the same thing.`, + ).toBe(false); + }); + + /** + * The other half of the ratchet. If the spec ever RETIRES the name that forced + * a rename, the rename is no longer load-bearing and the local dialect can go + * back to the natural name — this fails and says so, so the workaround cannot + * outlive its reason. + */ + it.each(RENAMES)('the spec still owns `%s` (second value: %s)', (_local, formerly) => { + expect( + SPEC_NAMES.has(formerly), + `@objectstack/spec no longer exports \`${formerly}\`, which is the only ` + + `reason this package renamed it. Either the spec dropped it (then take the ` + + `plain name back) or it moved (then re-check what it means now).`, + ).toBe(true); + }); +}); + +/** + * `FlowCanvasNode` / `FlowCanvasEdge` are the names one would naturally reach for + * when renaming the designer's node/edge types. They are already spec exports — + * and they mean the pure VISUAL OVERLAY (`{ nodeId, x, y, collapsed, … }`), not + * the node. Pinned so a future rename does not walk into them. + */ +describe('the obvious alternative flow names are already taken', () => { + it.each(['FlowCanvasNode', 'FlowCanvasEdge'])('`%s` belongs to the spec', (name) => { + expect(SPEC_NAMES.has(name)).toBe(true); + }); +}); + +/** + * Re-exports must be the spec's own binding, not a copy that happens to agree. + * Reference identity is the only check that can tell those apart — a faithful + * copy passes every value comparison (objectui#3003). + */ +describe('re-exported values are the spec binding itself', () => { + it('isAggregatedViewContainer IS the spec function', async () => { + const spec = await import('@objectstack/spec'); + expect(isAggregatedViewContainer).toBe(spec.isAggregatedViewContainer); + }); + + it('still behaves as the metadata list needs', () => { + expect(isAggregatedViewContainer({ list: {} })).toBe(true); + expect(isAggregatedViewContainer({ listViews: {} })).toBe(true); + // An already-expanded ViewItem carries the discriminant and is NOT a container. + expect(isAggregatedViewContainer({ viewKind: 'list', list: {} })).toBe(false); + expect(isAggregatedViewContainer({ name: 'x' })).toBe(false); + expect(isAggregatedViewContainer(null)).toBe(false); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Structural derivations — the three symbols that are neither a plain */ +/* re-export nor a rename. Each pins its ONE documented divergence, so the */ +/* divergence cannot silently grow and cannot silently outlive its reason. */ +/* -------------------------------------------------------------------------- */ + +/** Compile-time assertions. A violation is a `tsc` error, not a runtime failure. */ +type Assert = T; +type Extends = [A] extends [B] ? true : false; +type IsAny = 0 extends 1 & T ? true : false; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; + +describe('ScreenSpec derives from the spec, widening only `fields`', () => { + it('is pinned at compile time', () => { + // Guard against the probe lying: if either side erased to `any`, every + // assignability assertion below would pass while proving nothing + // (objectstack#4171 is exactly that failure for other symbols). + type _NotAny = Assert, false>>; + type _LocalNotAny = Assert, false>>; + + // The spec's screen is always a valid local screen: widening only ever adds. + type _SpecIsUsableHere = Assert>; + + // …but not the reverse, and for exactly one reason: `fields` is optional + // here. If this ever becomes `true`, the spec has made `fields` optional + // itself and this alias should collapse to a plain re-export. + type _StillWidened = Assert, false>>; + + // The widening is confined to `fields` — every other key is the spec's. + type _OnlyFieldsDiffers = Assert< + Extends, Omit> + >; + type _FieldsIsSpecFields = Assert< + Equal, SpecScreenFieldSpec[]> + >; + + // No key was invented locally, and none of the spec's was dropped. + type _NoLocalOnlyKeys = Assert, never>>; + type _NoMissingKeys = Assert, never>>; + + expect(true).toBe(true); + }); +}); + +describe('DecisionOutputDef derives from the spec, adding only `required`', () => { + it('is pinned at compile time', () => { + type _NotAny = Assert, false>>; + + // Every spec decision output is usable here. + type _SpecIsUsableHere = Assert>; + + // `required` is the ONLY local addition. When the spec adopts it, this + // becomes `never`, the assertion fails, and the interface should collapse + // to a plain re-export. + type _OnlyRequiredAdded = Assert< + Equal, 'required'> + >; + + // Deriving NARROWED `type` from the bare `string` this file used to declare + // to the spec's closed enum — that narrowing is the point, so pin it. + type _TypeIsClosed = Assert< + Equal + >; + + expect(true).toBe(true); + }); +}); + +describe('ObjectFieldGroup derives from the spec schema INPUT side', () => { + it('keeps `collapse` authorable (the z.input vs z.infer trap)', () => { + // `collapse` carries `.default('none')`, so it is optional to AUTHOR and + // required after parsing. This designer authors — `addGroup` emits + // `{ key, label }` — so the output type would make its own new-group shape + // unrepresentable. If this flips, someone swapped z.input for z.infer. + type _CollapseOptional = Assert>; + + // Still the real spec vocabulary, not a hand copy that merely agrees. + type _HasSpecKeys = Assert< + Extends< + 'key' | 'label' | 'icon' | 'description' | 'collapse' | 'collapsible' | 'collapsed' | 'defaultExpanded', + keyof ObjectFieldGroup + > + >; + type _NoInventedKeys = Assert< + Equal< + Exclude< + keyof ObjectFieldGroup, + 'key' | 'label' | 'icon' | 'description' | 'collapse' | 'collapsible' | 'collapsed' | 'defaultExpanded' + >, + never + > + >; + + expect(true).toBe(true); + }); +}); diff --git a/packages/app-shell/src/console/ai/ConversationsSidebar.tsx b/packages/app-shell/src/console/ai/ConversationsSidebar.tsx index 75923da52b..7e31a8d489 100644 --- a/packages/app-shell/src/console/ai/ConversationsSidebar.tsx +++ b/packages/app-shell/src/console/ai/ConversationsSidebar.tsx @@ -20,7 +20,7 @@ import { cn, } from '@object-ui/components'; import { agentAliasGroup, agentRouteName } from '@object-ui/plugin-chatbot'; -import { useConversationList, type ConversationSummary } from '../../hooks/useConversationList'; +import { useConversationList, type ConversationListItem } from '../../hooks/useConversationList'; export interface ConversationsSidebarProps { userId: string | undefined; @@ -73,7 +73,7 @@ export type ConversationGroupKey = 'today' | 'yesterday' | 'previous7Days' | 'pr export interface ConversationGroup { key: ConversationGroupKey; - items: ConversationSummary[]; + items: ConversationListItem[]; } const GROUP_ORDER: ConversationGroupKey[] = ['today', 'yesterday', 'previous7Days', 'previous30Days', 'older']; @@ -95,18 +95,18 @@ export const CONVERSATION_GROUP_LABELS: Record = { * component's render path). Empty sections are omitted. */ export function groupConversationsByDate( - conversations: ConversationSummary[], + conversations: ConversationListItem[], nowMs: number = Date.now(), ): ConversationGroup[] { const startOfToday = new Date(nowMs); startOfToday.setHours(0, 0, 0, 0); const todayMs = startOfToday.getTime(); const DAY = 24 * 60 * 60 * 1000; - const stamp = (c: ConversationSummary): number => { + const stamp = (c: ConversationListItem): number => { const v = new Date(c.updatedAt ?? c.createdAt ?? 0).getTime(); return Number.isNaN(v) ? 0 : v; }; - const buckets: Record = { + const buckets: Record = { today: [], yesterday: [], previous7Days: [], @@ -185,7 +185,7 @@ export function ConversationsSidebar({ // Navigate to a conversation on its OWN agent surface (so a lenient // cross-agent row still opens correctly); fall back to this surface. const conversationHref = useCallback( - (c: ConversationSummary) => { + (c: ConversationListItem) => { const seg = c.agentId ? agentRouteName(c.agentId) : agentRoute; return seg ? `/ai/${seg}/${c.id}` : `/ai/${c.id}`; }, @@ -305,7 +305,7 @@ export function ConversationsSidebar({ } interface RowProps { - conversation: ConversationSummary; + conversation: ConversationListItem; /** Active search query — matched substrings are highlighted in title/preview. */ query?: string; active: boolean; diff --git a/packages/app-shell/src/console/ai/__tests__/groupConversationsByDate.test.ts b/packages/app-shell/src/console/ai/__tests__/groupConversationsByDate.test.ts index 65916086f4..94fc53eb5d 100644 --- a/packages/app-shell/src/console/ai/__tests__/groupConversationsByDate.test.ts +++ b/packages/app-shell/src/console/ai/__tests__/groupConversationsByDate.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from 'vitest'; import { groupConversationsByDate } from '../ConversationsSidebar'; -import type { ConversationSummary } from '../../../hooks/useConversationList'; +import type { ConversationListItem } from '../../../hooks/useConversationList'; const DAY = 24 * 60 * 60 * 1000; // Fixed reference time. Offsets are chosen to land squarely in their bucket // regardless of the test runner's timezone (0 = today; exactly 24h = yesterday; // 4/15/60 days are unambiguous), so the calendar-midnight boundary can't flake. const now = Date.UTC(2026, 5, 13, 12, 0, 0); -const conv = (id: string, offset: number): ConversationSummary => - ({ id, updatedAt: new Date(now - offset).toISOString() }) as ConversationSummary; +const conv = (id: string, offset: number): ConversationListItem => + ({ id, updatedAt: new Date(now - offset).toISOString() }) as ConversationListItem; describe('groupConversationsByDate', () => { it('buckets into ordered recency sections', () => { @@ -32,7 +32,7 @@ describe('groupConversationsByDate', () => { [ conv('o2', 61 * DAY), conv('o1', 60 * DAY), - { id: 'bad', updatedAt: 'not-a-date' } as ConversationSummary, + { id: 'bad', updatedAt: 'not-a-date' } as ConversationListItem, ], now, ); @@ -42,7 +42,7 @@ describe('groupConversationsByDate', () => { it('falls back to createdAt when updatedAt is absent', () => { const groups = groupConversationsByDate( - [{ id: 'c', createdAt: new Date(now).toISOString() } as ConversationSummary], + [{ id: 'c', createdAt: new Date(now).toISOString() } as ConversationListItem], now, ); expect(groups[0]?.key).toBe('today'); diff --git a/packages/app-shell/src/console/marketplace/marketplaceApi.ts b/packages/app-shell/src/console/marketplace/marketplaceApi.ts index 3f6495f5d7..7238c63353 100644 --- a/packages/app-shell/src/console/marketplace/marketplaceApi.ts +++ b/packages/app-shell/src/console/marketplace/marketplaceApi.ts @@ -123,13 +123,16 @@ export interface MarketplacePackageDetail extends MarketplacePackageSummary { readme?: string | null; } -/** Structured permission grants a plugin requests (ADR-0025 §3.2). */ -export interface PluginPermissions { - services?: string[]; - hooks?: string[]; - network?: string[]; - fs?: string[]; -} +/** + * Structured permission grants a plugin requests (ADR-0025 §3.2). + * + * Owned by `@objectstack/spec/kernel` — the local copy was member-for-member + * identical, which is exactly the copy that silently rots the day the spec adds + * a grant kind (objectstack#4115). + */ +export type { PluginPermissions } from '@objectstack/spec/kernel'; + +import type { PluginPermissions } from '@objectstack/spec/kernel'; export interface MarketplacePackageVersion { id: string; diff --git a/packages/app-shell/src/hooks/index.ts b/packages/app-shell/src/hooks/index.ts index 3bd5664f0a..200f61c51c 100644 --- a/packages/app-shell/src/hooks/index.ts +++ b/packages/app-shell/src/hooks/index.ts @@ -43,7 +43,7 @@ export { } from './useChatConversation'; export { useConversationList, - type ConversationSummary, + type ConversationListItem, type UseConversationListOptions, type UseConversationListReturn, } from './useConversationList'; diff --git a/packages/app-shell/src/hooks/useConversationList.ts b/packages/app-shell/src/hooks/useConversationList.ts index e58aeb0906..1a6d158812 100644 --- a/packages/app-shell/src/hooks/useConversationList.ts +++ b/packages/app-shell/src/hooks/useConversationList.ts @@ -10,7 +10,19 @@ import { useCallback, useEffect, useState } from 'react'; -export interface ConversationSummary { +/** + * One row of the conversation-history list. + * + * Named `ConversationListItem`, not `ConversationSummary`: + * `@objectstack/spec/ai` already exports a `ConversationSummary`, and it is a + * different artifact entirely — the AI context-compaction record + * (`summary`, `keyPoints`, `originalTokens`, `summaryTokens`, `tokensSaved`, + * `messageRange`, `generatedAt`, `modelId`). The two share no key at all. This + * is the list row `GET /api/v1/ai/conversations` returns. + * `__tests__/spec-symbol-parity.test.ts` pins that the spec does not own this + * name (objectstack#4115). + */ +export interface ConversationListItem { id: string; title?: string; agentId?: string; @@ -29,7 +41,7 @@ export interface UseConversationListOptions { } export interface UseConversationListReturn { - conversations: ConversationSummary[]; + conversations: ConversationListItem[]; isLoading: boolean; error: Error | undefined; refetch: () => Promise; @@ -107,7 +119,7 @@ function stringifyContent(content: unknown): string | undefined { return undefined; } -function normalize(row: ServerConversation): ConversationSummary { +function normalize(row: ServerConversation): ConversationListItem { const title = row.title?.trim(); const preview = extractPreview(row) ?? stringifyContent(row.preview); return { @@ -123,7 +135,7 @@ function normalize(row: ServerConversation): ConversationSummary { async function fetchConversationDetail( apiBase: string, id: string, -): Promise { +): Promise { const res = await fetch(`${apiBase}/conversations/${encodeURIComponent(id)}`, { credentials: 'include', }); @@ -137,7 +149,7 @@ export function useConversationList( options: UseConversationListOptions, ): UseConversationListReturn { const { userId, apiBase, limit = 50, refreshKey } = options; - const [conversations, setConversations] = useState([]); + const [conversations, setConversations] = useState([]); const [isLoading, setIsLoading] = useState(Boolean(userId)); const [error, setError] = useState(undefined); const [internalKey, setInternalKey] = useState(0); @@ -220,7 +232,7 @@ export function useConversationList( if (cancelled) return; const byId = new Map( details - .filter((row): row is ConversationSummary => Boolean(row?.preview || row?.title)) + .filter((row): row is ConversationListItem => Boolean(row?.preview || row?.title)) .map((row) => [row.id, row]), ); if (byId.size > 0) { diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index e3f4ca5b22..af2f2c179f 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -123,7 +123,7 @@ export { isRuntimeConfigInitialised, resetRuntimeConfigForTesting, } from './runtime-config'; -export type { RuntimeConfig, RuntimeFeatures, RuntimeBranding, PlatformStage } from './runtime-config'; +export type { AppShellRuntimeConfig, RuntimeFeatures, RuntimeBranding, PlatformStage } from './runtime-config'; // Standard inner-SPA views export { diff --git a/packages/app-shell/src/layout/PageHeader.tsx b/packages/app-shell/src/layout/PageHeader.tsx index 2f96a3f327..ecb7124901 100644 --- a/packages/app-shell/src/layout/PageHeader.tsx +++ b/packages/app-shell/src/layout/PageHeader.tsx @@ -22,7 +22,25 @@ import * as React from 'react'; import { cn } from '@object-ui/components'; -export interface PageHeaderProps { +/** + * Props of the app-shell `` React component. + * + * Named `PageHeaderComponentProps`, not `PageHeaderProps`: + * `@objectstack/spec/ui` exports a `PageHeaderProps` **zod schema** describing + * the AUTHORED SDUI page-header node — `title: string`, `subtitle`, `icon` + * (an icon NAME), `breadcrumb`, `actions: string[]` (action ids), `aria`. This + * interface is the runtime React contract for the same visual element: + * `React.ReactNode` in place of every string, `actions` as rendered elements + * rather than ids, plus render-only knobs (`accentColor`, `sticky`, + * `className`, `data-testid`) the authored schema has no reason to model. + * Authored layer vs. rendered layer — two layers, one name, which is the + * objectstack#4115 defect. `__tests__/spec-symbol-parity.test.ts` pins that the spec + * does not own this name. + * + * `@object-ui/layout` carries the same collision (objectui#3161, batch 7); it + * should adopt this same name so the two packages do not invent two dialects. + */ +export interface PageHeaderComponentProps { /** Page title (required, becomes

). */ title: React.ReactNode; /** Optional secondary description shown beneath the title. */ @@ -79,7 +97,7 @@ export function PageHeader({ sticky = false, className, 'data-testid': testId, -}: PageHeaderProps) { +}: PageHeaderComponentProps) { // Resolve accent → CSS var. Falls back to the Shadcn primary token so the // header follows whatever brand the active app has injected. const accent = accentColor || 'hsl(var(--primary))'; diff --git a/packages/app-shell/src/layout/index.ts b/packages/app-shell/src/layout/index.ts index facf88ca1f..7b3cad4b98 100644 --- a/packages/app-shell/src/layout/index.ts +++ b/packages/app-shell/src/layout/index.ts @@ -13,4 +13,4 @@ export { PreviewBadge } from './PreviewBadge'; export type { PreviewBadgeProps } from './PreviewBadge'; export { AuthPageLayout } from './AuthPageLayout'; export { PageHeader } from './PageHeader'; -export type { PageHeaderProps } from './PageHeader'; +export type { PageHeaderComponentProps } from './PageHeader'; diff --git a/packages/app-shell/src/runtime-config.ts b/packages/app-shell/src/runtime-config.ts index 0140cab247..2840ca8ca3 100644 --- a/packages/app-shell/src/runtime-config.ts +++ b/packages/app-shell/src/runtime-config.ts @@ -84,7 +84,18 @@ export interface RuntimeBranding { pwaThemeColor?: string; } -export interface RuntimeConfig { +/** + * The SPA's server-pushed runtime configuration — which cloud to talk to, which + * features are on, and how the product is branded. + * + * Named `AppShellRuntimeConfig`, not `RuntimeConfig`: `@objectstack/spec/kernel` + * exports a `RuntimeConfig` that configures the ObjectStack ENGINE + * (`engine`, `engineConfig`, `resourceLimits`). The two share not one key — + * they are unrelated things that happened to pick the same noun + * (objectstack#4115). `__tests__/spec-symbol-parity.test.ts` pins that the spec + * does not own this name. + */ +export interface AppShellRuntimeConfig { /** * Upstream cloud base URL — the SPA dispatches install + env listing * directly against this origin. Empty string ⇒ same-origin (i.e. the @@ -99,7 +110,7 @@ export interface RuntimeConfig { branding: RuntimeBranding; } -const defaults: RuntimeConfig = { +const defaults: AppShellRuntimeConfig = { cloudUrl: '', singleEnvironment: false, defaultOrgId: null, @@ -117,11 +128,11 @@ function isPlatformStage(value: unknown): value is PlatformStage { return typeof value === 'string' && (PLATFORM_STAGES as readonly string[]).includes(value); } -let current: RuntimeConfig = { ...defaults }; +let current: AppShellRuntimeConfig = { ...defaults }; let initialised = false; /** Apply a partial update over the singleton. */ -function applyUpdate(patch: Partial): void { +function applyUpdate(patch: Partial): void { current = { ...current, ...patch, @@ -154,7 +165,7 @@ export async function initRuntimeConfig(baseUrl: string = ''): Promise { headers: { Accept: 'application/json' }, }); if (!res.ok) return; - const body = (await res.json()) as Partial | null; + const body = (await res.json()) as Partial | null; if (!body || typeof body !== 'object') return; applyUpdate({ cloudUrl: typeof body.cloudUrl === 'string' ? body.cloudUrl.replace(/\/+$/, '') : current.cloudUrl, @@ -214,7 +225,7 @@ export async function initRuntimeConfig(baseUrl: string = ''): Promise { } /** Read-only accessor. Returns the current snapshot. */ -export function getRuntimeConfig(): RuntimeConfig { +export function getRuntimeConfig(): AppShellRuntimeConfig { return current; } diff --git a/packages/app-shell/src/utils/decisionOutputParams.ts b/packages/app-shell/src/utils/decisionOutputParams.ts index 6bfbecd265..6f7d1218c4 100644 --- a/packages/app-shell/src/utils/decisionOutputParams.ts +++ b/packages/app-shell/src/utils/decisionOutputParams.ts @@ -34,19 +34,26 @@ * belonged (objectui#2955). Emit `reference`, never `referenceTo`. */ -/** An approval node's declared decision output, as the server surfaces it. */ -export interface DecisionOutputDef { - key: string; - label?: string; - /** Record kind to pick (`user` / `department` / `position` / `team`); absent → free text. */ - type?: string; - multiple?: boolean; - /** - * The approver must supply this one to APPROVE. The server enforces it - * (`decide()` rejects a blank required output before any write); the dialog - * mirrors it so the approver is stopped at the field rather than by a 400. - * Absent on a backend that predates the flag — then nothing is required. - */ +import type { DecisionOutputDef as SpecDecisionOutputDef } from '@objectstack/spec/automation'; + +/** + * An approval node's declared decision output, as the server surfaces it. + * + * Derived from `@objectstack/spec/automation`'s `DecisionOutputDef` (structural + * `extends`, objectstack#4115) with ONE local addition. Deriving also narrows + * `type` from the bare `string` this file used to declare to the spec's closed + * `'user' | 'department' | 'position' | 'team' | 'text'` enum — a typo'd kind + * now fails to compile instead of silently degrading to a raw record-id text + * box, which is the objectui#2955 failure this module exists to prevent. + * + * `required` is the documented divergence: the server enforces it (`decide()` + * rejects a blank required output before any write) and the dialog mirrors it + * so the approver is stopped at the field rather than by a 400 — but the spec + * does not model it yet. It is optional here so a backend predating the flag + * still parses. When the spec adopts `required`, this interface collapses to a + * plain re-export; `__tests__/spec-symbol-parity.test.ts` fails on that day. + */ +export interface DecisionOutputDef extends SpecDecisionOutputDef { required?: boolean; } diff --git a/packages/app-shell/src/views/ScreenView.tsx b/packages/app-shell/src/views/ScreenView.tsx index 58afcee2db..de89f90737 100644 --- a/packages/app-shell/src/views/ScreenView.tsx +++ b/packages/app-shell/src/views/ScreenView.tsx @@ -30,47 +30,37 @@ import { import { ObjectForm } from '@object-ui/plugin-form'; import { evalFieldPredicate } from '@object-ui/core'; -export interface ScreenFieldSpec { - name: string; - label?: string; - type?: string; - required?: boolean; - options?: Array<{ value: unknown; label: string }>; - defaultValue?: unknown; - placeholder?: string; - /** - * Conditional-visibility predicate — bare CEL over the screen's own field - * names (`createOpportunity == true`), re-evaluated against the values - * collected so far. Omit = always visible. Read it through - * {@link visibleScreenFields}, never field-by-field, so rendering and - * `required` enforcement can never disagree (#3528). - */ - visibleWhen?: string; -} -export interface ScreenSpec { - nodeId: string; - title?: string; - description?: string; - /** - * Optional on the wire: an `object-form` step (or a message-only screen from - * a third-party node executor) can legitimately omit it, so every read goes - * through {@link screenFields} rather than touching the array directly — an - * absent `fields` used to throw the moment the dialog opened. - */ - fields?: ScreenFieldSpec[]; - /** - * `'object-form'` renders the named object's FULL create/edit form — incl. - * inline master-detail child grids — as a wizard step (vs. the flat `fields` - * list). The form persists the record (and its children, atomically) itself, - * then resumes the run with the saved id bound to `idVariable`. - */ - kind?: 'fields' | 'object-form'; - objectName?: string; - mode?: 'create' | 'edit'; - recordId?: string; - defaults?: Record; - idVariable?: string; -} +/** + * The screen-pause contract is OWNED by `@objectstack/spec/contracts` + * (objectstack#4115) — the server emits it, this dialog renders it. + * + * `ScreenFieldSpec` is re-exported verbatim: the local copy was byte-equivalent + * (including `visibleWhen`, ADR-0089's canonical spelling), so there was + * nothing to keep. + */ +export type { ScreenFieldSpec } from '@objectstack/spec/contracts'; + +import type { + ScreenFieldSpec, + ScreenSpec as SpecScreenSpec, +} from '@objectstack/spec/contracts'; + +/** + * The spec's screen contract with ONE deliberate widening: `fields` is optional + * here and required there. + * + * Derived structurally from `SpecScreenSpec` so every other key — and any key + * the spec adds later — arrives automatically; only the documented divergence + * is spelled out. An `object-form` step, or a message-only screen emitted by a + * third-party node executor, legitimately carries no `fields` array, and an + * absent `fields` used to throw the moment the dialog opened. Every read goes + * through {@link screenFields} rather than touching the array directly, so the + * widening cannot leak into rendering or `required` enforcement (#3528). + * + * If the spec ever makes `fields` optional itself, this alias collapses to a + * plain re-export — `__tests__/spec-symbol-parity.test.ts` fails on that day and says so. + */ +export type ScreenSpec = Omit & { fields?: ScreenFieldSpec[] }; /** Whether a screen renders the object-form body rather than the flat fields. */ export function isObjectFormScreen(screen: ScreenSpec): boolean { @@ -224,14 +214,24 @@ export function ScreenView({ screen, values, onValueChange, dataSource, objects, {f.label || f.name} {f.required && *} - onValueChange(f.name, v)} /> + onValueChange(f.name, v)} /> ))} ); } -export function FieldInput({ field, value, onChange }: { field: ScreenFieldSpec; value: unknown; onChange: (v: unknown) => void }) { +/** + * One screen field's edit control. + * + * Named `ScreenFieldInput`, not `FieldInput`: `@objectstack/spec/data` already + * exports a `FieldInput` type — the authoring shape of an object FIELD + * (`Omit, 'type'>`) — which has nothing to do with this React + * component. Two unrelated things under one name is the objectstack#4115 + * defect; `__tests__/spec-symbol-parity.test.ts` pins that the spec does not own + * this name. + */ +export function ScreenFieldInput({ field, value, onChange }: { field: ScreenFieldSpec; value: unknown; onChange: (v: unknown) => void }) { const id = `ff-${field.name}`; const t = (field.type || 'text').toLowerCase(); diff --git a/packages/app-shell/src/views/metadata-admin/AccessExplainPanel.tsx b/packages/app-shell/src/views/metadata-admin/AccessExplainPanel.tsx index 46bf43d8a9..fe24df454c 100644 --- a/packages/app-shell/src/views/metadata-admin/AccessExplainPanel.tsx +++ b/packages/app-shell/src/views/metadata-admin/AccessExplainPanel.tsx @@ -53,79 +53,46 @@ import { import { t, tFormat, useMetadataLocale } from './i18n'; import { useMetadataClient } from './useMetadata'; -/** Mirrors `ExplainOperationSchema` in `@objectstack/spec/security`. */ -const OPERATIONS = ['read', 'create', 'update', 'delete', 'transfer', 'restore', 'purge'] as const; -type ExplainOperation = (typeof OPERATIONS)[number]; - -/** Pipeline layer ids — mirrors `ExplainLayerSchema.layer` (C2 adds `tenant_isolation`). */ -export type ExplainLayerId = - | 'tenant_isolation' - | 'principal' - | 'required_permissions' - | 'object_crud' - | 'fls' - | 'owd_baseline' - | 'depth' - | 'sharing' - | 'vama_bypass' - | 'rls'; - -/** [C2 / ADR-0095] One concrete rule that governed a specific record at a layer. */ -export interface ExplainMatchedRule { - kind: - | 'tenant_filter' - | 'owd_baseline' - | 'ownership' - | 'record_share' - | 'sharing_rule' - | 'team' - | 'territory' - | 'rls_policy'; - name: string; - grants?: 'read' | 'edit' | 'full'; - via?: string; - predicate?: unknown; - effect: 'admits' | 'excludes' | 'neutral'; -} - -/** [C2 / ADR-0095] A layer's row-level determination for one record. */ -export interface ExplainRecordAttribution { - outcome: 'admitted' | 'excluded' | 'not_evaluated'; - rowFilter?: unknown; - matchesRecord?: boolean; - rules?: ExplainMatchedRule[]; - detail?: string; -} - -/** Mirrors `ExplainDecisionSchema` in `@objectstack/spec/security` (ADR-0090 D6 / C2 ADR-0095). */ -export interface ExplainLayer { - layer: ExplainLayerId; - verdict: 'grants' | 'denies' | 'narrows' | 'widens' | 'neutral' | 'not_applicable'; - detail: string; - contributors?: Array<{ kind: 'permission_set' | 'position' | 'system'; name: string; via?: string }>; - /** [C2 / ADR-0095 D1] Kernel tier — the tenant wall (Layer 0) vs. business RLS (Layer 1). */ - kernelTier?: 'layer_0_tenant' | 'layer_1_business'; - /** [C2 / ADR-0095] Per-record row story; present only on record-grained reports. */ - record?: ExplainRecordAttribution; -} -export interface ExplainDecision { - allowed: boolean; - object: string; - operation: ExplainOperation; - principal: { - userId: string | null; - positions?: string[]; - permissionSets?: string[]; - principalKind?: string; - onBehalfOf?: { userId: string }; - /** [C2 / ADR-0095 D2] Posture rung, when resolved (record-grained reports). */ - posture?: 'PLATFORM_ADMIN' | 'TENANT_ADMIN' | 'MEMBER' | 'EXTERNAL'; - }; - layers: ExplainLayer[]; - readFilter?: unknown; - /** [C2 / ADR-0095] Row-level verdict for the specific record under explanation. */ - record?: { recordId: string; visible: boolean; decidedBy?: ExplainLayerId }; -} +/** + * The explain-report vocabulary is OWNED by `@objectstack/spec/security` and + * imported here, not re-described (objectstack#4115). + * + * The report this panel renders is produced by the framework's explain engine + * parsing with these very schemas, so a local copy can only ever be a lagging + * transcription of them. The copies that used to live here had already drifted + * in three places, each of which silently degraded the panel: + * + * - `ExplainRecordAttribution.rules` was optional here but is REQUIRED in the + * spec — the "which rule decided this row" list is always sent, so the + * optional spelling pushed every reader through a needless nullish branch. + * - `ExplainLayer.contributors[].state` (`'active' | 'expired'`) was missing + * outright, so an EXPIRED position/permission-set contribution rendered + * identically to a live one. + * - `ExplainDecision.principal.positions` / `.permissionSets` were optional + * here and required in the spec, and `principalKind` was a bare `string` + * rather than the closed `'human' | 'agent' | 'service' | 'system' | + * 'guest'` enum. + */ +export type { + ExplainDecision, + ExplainLayer, + ExplainMatchedRule, + ExplainRecordAttribution, +} from '@objectstack/spec/security'; + +import type { + ExplainDecision, + ExplainLayer, + ExplainMatchedRule, + ExplainRecordAttribution, +} from '@objectstack/spec/security'; + +/** Operation vocabulary, derived from the spec's own report type. */ +type ExplainOperation = ExplainDecision['operation']; +const OPERATIONS = ['read', 'create', 'update', 'delete', 'transfer', 'restore', 'purge'] as const satisfies readonly ExplainOperation[]; + +/** Pipeline layer ids, derived from the spec's `ExplainLayer` (C2 adds `tenant_isolation`). */ +export type ExplainLayerId = ExplainLayer['layer']; const VERDICT_BADGE: Record = { grants: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400', diff --git a/packages/app-shell/src/views/metadata-admin/EditPackageDialog.test.tsx b/packages/app-shell/src/views/metadata-admin/EditPackageDialog.test.tsx index a67bbe4e49..1e7f013668 100644 --- a/packages/app-shell/src/views/metadata-admin/EditPackageDialog.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/EditPackageDialog.test.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { EditPackageDialog, type InstalledPackage } from './PackagesPage'; +import { EditPackageDialog, type InstalledPackageRow } from './PackagesPage'; // EditPackageDialog is now a thin wrapper over the spec-driven PackageFormDialog // (edit mode). It renders the manifest form via SchemaForm and PATCHes only the @@ -11,7 +11,7 @@ import { EditPackageDialog, type InstalledPackage } from './PackagesPage'; // PackageFormDialog's apiJson uses the raw global fetch (fetch → res.text() → // JSON.parse), so we stub global fetch and mirror the runtime's { success, data } // envelope — exactly what `PATCH /api/v1/packages/:id` returns. -const PKG: InstalledPackage = { +const PKG: InstalledPackageRow = { manifest: { id: 'com.acme.crm', name: 'Acme CRM', version: '1.0.0', description: 'old', type: 'app' }, enabled: true, status: 'installed', @@ -26,7 +26,7 @@ beforeEach(() => { vi.fn(async (url: string, init: RequestInit = {}) => { calls.push({ url, init }); const body = init.body ? JSON.parse(init.body as string) : {}; - const updated: InstalledPackage = { ...PKG, manifest: { ...PKG.manifest, ...body } }; + const updated: InstalledPackageRow = { ...PKG, manifest: { ...PKG.manifest, ...body } }; return { ok: true, status: 200, diff --git a/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx b/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx index 774bc349f1..96705ed1d3 100644 --- a/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx @@ -68,21 +68,48 @@ import { errorCodeIs } from '@object-ui/types'; /* Types + API */ /* -------------------------------------------------------------------------- */ -export interface PackageManifest { +/** + * The subset of a package manifest this admin LIST renders, kept open. + * + * Named `PackageManifestRow`, not `PackageManifest`, and deliberately NOT + * derived (objectstack#4115). `@objectstack/spec/cloud` owns `PackageManifest` + * — the full authored manifest, with `defaultDatasource`, `type`, `scope`, + * `dependencies`, `contributes`, `capabilities`, `sandboxing` and ~30 more keys + * required or modelled. This page reads whatever `/api/v1/packages` happens to + * return, from runtimes of different vintages, and renders six columns from it; + * every key is optional here because an older control plane really does omit + * them, and the index signature is load-bearing — it lets a row flow into the + * spec-driven `PackageFormDialog` (which types the manifest as a loose record) + * without a cast, and preserves keys this page does not render. + * + * So this is a lenient READ PROJECTION over the spec's manifest, not a second + * definition of it: it must stay assignable-from anything the wire sends, which + * is the opposite of what the spec type is for. `__tests__/spec-symbol-parity.test.ts` + * pins that the spec does not own the `…Row` name and that the projection's keys + * are a subset of the spec manifest's, so a key invented here fails loudly. + */ +export interface PackageManifestRow { id: string; name?: string; version?: string; type?: string; scope?: 'cloud' | 'system' | 'project'; description?: string; - // The manifest carries many more spec fields (namespace, dependencies, …); - // the index signature lets it flow into the spec-driven PackageFormDialog - // (which types the manifest as a loose record) without a cast. [key: string]: unknown; } -export interface InstalledPackage { - manifest: PackageManifest; +/** + * One installed-package row, as `/api/v1/packages` returns it. + * + * Named `InstalledPackageRow` for the same reason: the spec's + * `InstalledPackage` (`@objectstack/spec/kernel`) requires `manifest`, + * `status` and `enabled`, types `manifest` as the full spec `PackageManifest`, + * and adds `installedAt`, `updatedAt`, `installedVersion`, `previousVersion`, + * `settings`, `upgradeHistory` and `registeredNamespaces`. This row is the + * lenient projection of it that the list actually consumes. + */ +export interface InstalledPackageRow { + manifest: PackageManifestRow; status?: string; enabled?: boolean; statusChangedAt?: string; @@ -142,7 +169,7 @@ function ScopeBadge({ scope }: { scope?: string }) { return {labelKey ? t(labelKey, locale) : scope}; } -function StatusBadge({ pkg }: { pkg: InstalledPackage }) { +function StatusBadge({ pkg }: { pkg: InstalledPackageRow }) { const locale = useMetadataLocale(); const enabled = pkg.enabled !== false && pkg.status !== 'disabled'; return ( @@ -207,10 +234,10 @@ export function EditPackageDialog({ onOpenChange, onSaved, }: { - pkg: InstalledPackage | null; + pkg: InstalledPackageRow | null; open: boolean; onOpenChange: (v: boolean) => void; - onSaved: (updated: InstalledPackage) => void; + onSaved: (updated: InstalledPackageRow) => void; }) { return ( onSaved((r.package as unknown as InstalledPackage) ?? (pkg as InstalledPackage))} + onSaved={(r) => onSaved((r.package as unknown as InstalledPackageRow) ?? (pkg as InstalledPackageRow))} /> ); } @@ -230,7 +257,7 @@ export function PackageDetailSheet({ onOpenChange, onChanged, }: { - pkg: InstalledPackage | null; + pkg: InstalledPackageRow | null; /** Base for metadata-browse / draft-review links. Omit (e.g. in Studio, which * has no console app context) to hide those links; every lifecycle action * still works without it. */ @@ -699,13 +726,13 @@ export function PackagesPage() { return idx >= 0 ? pathname.slice(0, idx) : pathname; }, [pathname]); - const [packages, setPackages] = React.useState([]); + const [packages, setPackages] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [query, setQuery] = React.useState(''); const [showKernel, setShowKernel] = React.useState(false); const [createOpen, setCreateOpen] = React.useState(false); - const [selected, setSelected] = React.useState(null); + const [selected, setSelected] = React.useState(null); const [detailOpen, setDetailOpen] = React.useState(false); const fileRef = React.useRef(null); const [importing, setImporting] = React.useState(false); @@ -717,7 +744,7 @@ export function PackagesPage() { setLoading(true); setError(null); try { - const data = await apiJson<{ packages: InstalledPackage[] }>(API); + const data = await apiJson<{ packages: InstalledPackageRow[] }>(API); const list = Array.isArray(data?.packages) ? data.packages : []; list.sort((a, b) => { // User (project) packages first, then by name. @@ -758,7 +785,7 @@ export function PackagesPage() { }); }, [packages, query, showKernel]); - const openDetail = (pkg: InstalledPackage) => { + const openDetail = (pkg: InstalledPackageRow) => { setSelected(pkg); setDetailOpen(true); }; diff --git a/packages/app-shell/src/views/metadata-admin/external/ValidationPanel.tsx b/packages/app-shell/src/views/metadata-admin/external/ValidationPanel.tsx index e298d25119..40cff783b9 100644 --- a/packages/app-shell/src/views/metadata-admin/external/ValidationPanel.tsx +++ b/packages/app-shell/src/views/metadata-admin/external/ValidationPanel.tsx @@ -28,6 +28,16 @@ export interface ValidationPanelProps { type RunState = 'idle' | 'running' | 'done' | 'error' | 'unavailable'; +/** + * Every diff kind the server can report, labelled. + * + * Total over `SchemaDiffEntry['kind']` on purpose — now that the kind union is + * imported from `@objectstack/spec/shared` rather than transcribed locally, a + * kind added upstream fails THIS map to compile instead of rendering a blank + * cell. `index_mismatch` and `unmapped_index` (framework#3728) were exactly + * that: already emitted by the validate route, absent from the local copy of + * the union, and therefore silently unlabelled here (objectstack#4115). + */ const DIFF_LABEL: Record = { missing_table: 'Missing table', missing_column: 'Missing column', @@ -35,6 +45,8 @@ const DIFF_LABEL: Record = { nullability_mismatch: 'Nullability mismatch', unmapped_column: 'Unmapped column', pk_mismatch: 'Primary-key mismatch', + index_mismatch: 'Index mismatch', + unmapped_index: 'Unmapped index', }; export function ValidationPanel({ datasource }: ValidationPanelProps) { diff --git a/packages/app-shell/src/views/metadata-admin/external/api.ts b/packages/app-shell/src/views/metadata-admin/external/api.ts index c9f590d901..8196d1c3bf 100644 --- a/packages/app-shell/src/views/metadata-admin/external/api.ts +++ b/packages/app-shell/src/views/metadata-admin/external/api.ts @@ -21,92 +21,55 @@ */ import { createAuthenticatedFetch } from '@object-ui/auth'; +import type { + GenerateDraftOpts, + ObjectDraft, + RemoteTable, + SchemaValidationResult, +} from '@objectstack/spec/contracts'; +import type { ExternalCatalog } from '@objectstack/spec/data'; // --------------------------------------------------------------------------- -// Contract types — mirror `@objectstack/spec` (external-datasource-service.ts, -// external-catalog.zod.ts, external-errors.ts). Kept local so app-shell does -// not take a build dependency on the framework spec package. +// Contract types — RE-EXPORTED from `@objectstack/spec`, not mirrored. +// +// These nine used to be hand-written copies under the spec's own names, with a +// comment claiming they were "kept local so app-shell does not take a build +// dependency on the framework spec package". That reason was already false: +// `@objectstack/spec` is a direct dependency of this package (package.json), +// and the copies had drifted (objectstack#4115) — `SchemaDiffEntryKind` was +// missing `index_mismatch` and `unmapped_index`, so a validate run that +// reported an index divergence hit a `kind` this UI could not name, and +// `ExternalColumn.primaryKey` was optional here while the server always sends +// it (the spec schema defaults it to `false`). +// +// The wire shapes are produced by the framework parsing with these very +// schemas, so the spec's types are the accurate ones by construction. Import +// them; do not re-describe them. // --------------------------------------------------------------------------- -/** A remote table discovered via introspection (allowedSchemas-filtered). */ -export interface RemoteTable { - schema?: string; - name: string; - columnCount: number; - rowCountEstimate?: number; -} - -/** Options controlling how a remote table is drafted into an Object. */ -export interface GenerateDraftOpts { - remoteSchema?: string; - rename?: Record; - primaryKey?: string[]; - includeColumns?: string[]; - excludeColumns?: string[]; -} - -/** A generated Object draft: structured definition + `*.object.ts` source. */ -export interface ObjectDraft { - name: string; - datasource: string; - definition: Record; - source: string; - review: Array<{ column: string; remoteType: string; note: string }>; -} - -export type SchemaDiffEntryKind = - | 'missing_table' - | 'missing_column' - | 'type_mismatch' - | 'nullability_mismatch' - | 'unmapped_column' - | 'pk_mismatch'; - -/** A single divergence between a federated Object and its remote table. */ -export interface SchemaDiffEntry { - kind: SchemaDiffEntryKind; - remoteSchema?: string; - remoteName?: string; - column?: string; - expected?: string; - actual?: string; - severity: 'error' | 'warning'; -} - -/** Per-object validation outcome. */ -export interface SchemaValidationResult { - ok: boolean; - datasource: string; - object: string; - diffs: SchemaDiffEntry[]; -} - -/** A single remote column captured in a catalog snapshot. */ -export interface ExternalColumn { - name: string; - sqlType: string; - nullable: boolean; - primaryKey?: boolean; - suggestedFieldType?: string; -} +/** + * Introspection + drafting contracts (ADR-0015 §6.2), owned by + * `@objectstack/spec/contracts`. + */ +export type { + RemoteTable, + GenerateDraftOpts, + ObjectDraft, + SchemaValidationResult, +} from '@objectstack/spec/contracts'; -/** A single remote table/view captured in a catalog snapshot. */ -export interface ExternalTable { - remoteSchema?: string; - remoteName: string; - columns: ExternalColumn[]; - indexes?: Array<{ name: string; columns: string[]; unique: boolean }>; - rowCountEstimate?: number; -} +/** + * Schema-divergence vocabulary, owned by `@objectstack/spec/shared` — shared + * with the framework's `external-errors` module so a diff `kind` this UI + * renders is exactly a `kind` the server can emit. + */ +export type { SchemaDiffEntry, SchemaDiffEntryKind } from '@objectstack/spec/shared'; -/** The persisted snapshot of a federated datasource's remote schema. */ -export interface ExternalCatalog { - name: string; - datasource: string; - snapshotAt: string; - dialect?: string; - tables: ExternalTable[]; -} +/** + * Catalog-snapshot shapes, owned by `@objectstack/spec/data` (the + * `ExternalCatalogSchema` family the refresh-catalog route parses with). + */ +export type { ExternalCatalog, ExternalColumn, ExternalTable } from '@objectstack/spec/data'; /** * Raised when the server replies `503 external_service_unavailable` — the diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.ts b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.ts index 7ff69092ee..314d05c5b0 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.ts @@ -28,7 +28,25 @@ const MONGO_TO_OP: Record = { export interface BuilderCondition { id?: string; field: string; operator: string; value?: unknown } export interface BuilderGroup { id?: string; logic: 'and' | 'or'; conditions: BuilderCondition[] } -export type FilterCondition = Record; + +/** + * The ObjectQL filter AST, owned by `@objectstack/spec/data`. + * + * This was `Record` — a local declaration under the spec's own + * name that carried none of its structure (objectstack#4115). Re-exporting the + * real recursive type restores `$and` / `$or` / `$not` and the per-field + * operator shape, so the bridge below is checked against what actually gets + * stored on `dataset.filter` / `measure.filter`. + * + * NOTE for the sibling batches: unlike `@object-ui/types` and + * `@object-ui/components`, app-shell's `FilterCondition` was NOT the + * FilterBuilder row — that concept lives here under its own name, + * `BuilderCondition` (above), and needed no rename. Do not fold this one into + * the `FilterBuilderCondition` rename. + */ +export type { FilterCondition } from '@objectstack/spec/data'; + +import type { FilterCondition } from '@objectstack/spec/data'; /** Serialize the visual group → a spec FilterCondition (flat `$and`). */ export function groupToCondition(group: BuilderGroup | undefined): FilterCondition | undefined { diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-nested-selection.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-nested-selection.test.ts index 56779900ec..a8d95475b7 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-nested-selection.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-nested-selection.test.ts @@ -10,7 +10,7 @@ import { locateFlowNode, type NestedNodePath, } from './flow-nested-selection'; -import { extractRegions, type FlowNode } from '../previews/flow-canvas-layout'; +import { extractRegions, type FlowDesignerNode } from '../previews/flow-canvas-layout'; describe('flow-nested-selection — id codec', () => { it('exports a stable, distinct selection kind', () => { @@ -73,7 +73,7 @@ describe('flow-nested-selection — region config path + label', () => { * must never disagree on the set of region keys. */ it('resolves every key extractRegions emits (loop / parallel / try_catch)', () => { - const containers: FlowNode[] = [ + const containers: FlowDesignerNode[] = [ { id: 'each', type: 'loop', config: { body: { nodes: [{ id: 'x', type: 'http' }], edges: [] } } }, { id: 'fan', diff --git a/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx b/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx index 96d9d100c1..cc21f9415a 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx @@ -41,8 +41,8 @@ import { edgeKey, conditionText, extractRegions, - type FlowNode, - type FlowEdge, + type FlowDesignerNode, + type FlowDesignerEdge, type Point, type LabeledRegion, } from './flow-canvas-layout'; @@ -73,8 +73,8 @@ interface PanState { } export interface FlowCanvasProps { - nodes: FlowNode[]; - edges: FlowEdge[]; + nodes: FlowDesignerNode[]; + edges: FlowDesignerEdge[]; editable: boolean; designMode: boolean; selectedId: string | null; @@ -108,9 +108,9 @@ export interface FlowCanvasProps { * driven separately via `selectedId` / `selectedEdgeId`. */ revealSignal?: { target: FlowProblem['target']; nonce: number } | null; - onSelect: (node: FlowNode | null) => void; + onSelect: (node: FlowDesignerNode | null) => void; /** Select an edge (its `edgeKey`), or clear selection with `null`. */ - onSelectEdge?: (edge: FlowEdge | null, key: string) => void; + onSelectEdge?: (edge: FlowDesignerEdge | null, key: string) => void; /** * #2670 Phase 3: the selected NESTED node (inside an expanded container's * region), or null. Drives the selection ring on the matching container's @@ -122,7 +122,7 @@ export interface FlowCanvasProps { * clear the nested selection with `null`. Absent → the region trays stay * read-only (Phase 2 behavior). */ - onSelectNested?: (path: NestedNodePath | null, node?: FlowNode) => void; + onSelectNested?: (path: NestedNodePath | null, node?: FlowDesignerNode) => void; onPatch?: (partial: Record) => void; } @@ -243,7 +243,7 @@ export function FlowCanvas({ const idx = nodes.findIndex((n) => n.id === id); if (idx < 0) return; const node = nodes[idx]; - const nextNode: FlowNode = { ...node, ui: { ...(node.ui ?? {}), x, y } }; + const nextNode: FlowDesignerNode = { ...node, ui: { ...(node.ui ?? {}), x, y } }; onPatch({ nodes: spliceArray(nodes, idx, nextNode) }); }, [nodes, onPatch], @@ -260,11 +260,11 @@ export function FlowCanvas({ // spaces it horizontally among siblings — pinning it directly under the // parent (the old behavior) made every sibling stack on the same spot. const at = opts?.at; - const newNode: FlowNode = { id, type, label, ...defaultNodeExtras(type), ...(at ? { ui: { x: at.x, y: at.y } } : {}) }; + const newNode: FlowDesignerNode = { id, type, label, ...defaultNodeExtras(type), ...(at ? { ui: { x: at.x, y: at.y } } : {}) }; const nextNodes = appendArray(nodes, newNode); const patch: Record = { nodes: nextNodes }; if (opts?.from) { - const newEdge: FlowEdge = { + const newEdge: FlowDesignerEdge = { id: uniqueId('edge', edges.map((e) => e.id).filter(Boolean) as string[]), source: opts.from, target: id, @@ -299,7 +299,7 @@ export function FlowCanvas({ /** Split edge A→B by inserting a new node N: A→N (keeps guard) + N→B. */ const insertOnEdge = React.useCallback( - (edge: FlowEdge, type = 'create_record') => { + (edge: FlowDesignerEdge, type = 'create_record') => { if (!onPatch) return; const edgeIdx = edges.findIndex((e) => e === edge); if (edgeIdx < 0) return; @@ -308,7 +308,7 @@ export function FlowCanvas({ const from = positionOf(edge.source); const to = positionOf(edge.target); const at = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 }; - const newNode: FlowNode = { + const newNode: FlowDesignerNode = { id, type, label: defaultNodeLabel(type, locale), @@ -316,8 +316,8 @@ export function FlowCanvas({ ui: { x: at.x, y: at.y }, }; // A→N inherits the original edge's branch semantics; N→B is plain. - const firstSegment: FlowEdge = { ...edge, target: id }; - const secondSegment: FlowEdge = { + const firstSegment: FlowDesignerEdge = { ...edge, target: id }; + const secondSegment: FlowDesignerEdge = { id: uniqueId('edge', [...edges.map((e) => e.id).filter(Boolean) as string[], 'edge']), source: id, target: edge.target, @@ -343,7 +343,7 @@ export function FlowCanvas({ if (!onPatch) return; if (!nodes.some((n) => n.id === approvalId)) return; const waitId = uniqueId('node', nodes.map((n) => n.id).filter(Boolean) as string[]); - const waitNode: FlowNode = { + const waitNode: FlowDesignerNode = { id: waitId, type: 'wait', label: tr('engine.flowCanvas.awaitingRevision', locale), @@ -353,8 +353,8 @@ export function FlowCanvas({ const existingEdgeIds = edges.map((e) => e.id).filter(Boolean) as string[]; const reviseId = uniqueId('edge', existingEdgeIds); const backId = uniqueId('edge', [...existingEdgeIds, reviseId]); - const reviseEdge: FlowEdge = { id: reviseId, source: approvalId, target: waitId, label: 'revise' }; - const backEdge: FlowEdge = { id: backId, source: waitId, target: approvalId, label: 'resubmit', type: 'back' }; + const reviseEdge: FlowDesignerEdge = { id: reviseId, source: approvalId, target: waitId, label: 'revise' }; + const backEdge: FlowDesignerEdge = { id: backId, source: waitId, target: approvalId, label: 'resubmit', type: 'back' }; onPatch({ nodes: appendArray(nodes, waitNode), edges: appendArray(appendArray(edges, reviseEdge), backEdge), @@ -912,7 +912,7 @@ export function FlowCanvas({ } /** One-line config summary shown on the node card (best-effort, type-aware). */ -function nodeSummary(node: FlowNode): string | undefined { +function nodeSummary(node: FlowDesignerNode): string | undefined { const c = node.config as Record | undefined; const str = (v: unknown) => (typeof v === 'string' && v ? v : undefined); const block = (key: string, inner: string) => { diff --git a/packages/app-shell/src/views/metadata-admin/previews/ObjectFormCanvas.tsx b/packages/app-shell/src/views/metadata-admin/previews/ObjectFormCanvas.tsx index bb93d0dc33..20d357672c 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/ObjectFormCanvas.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/ObjectFormCanvas.tsx @@ -59,7 +59,7 @@ import { clearFieldGroup, diffFields, type FieldEntry, - type FieldGroup, + type ObjectFieldGroup, type FieldsDiff, type FieldDiffStatus, } from './object-fields-io'; @@ -129,7 +129,7 @@ export function ObjectFormCanvas({ reviewing ? diff?.byName[name]?.status : undefined; const changedKeysOf = (name: string): string[] => reviewing ? diff?.byName[name]?.changedKeys ?? [] : []; - const declaredGroups = React.useMemo( + const declaredGroups = React.useMemo( () => readGroups((draft as any).fieldGroups), [draft], ); @@ -679,7 +679,7 @@ function BulkActionBar({ locale, }: { count: number; - groups: FieldGroup[]; + groups: ObjectFieldGroup[]; onMoveToGroup: (groupKey: string | null) => void; onDelete: () => void; onClear: () => void; diff --git a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.test.ts b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.test.ts index 1a7ace51ee..aa08411857 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.test.ts @@ -15,8 +15,8 @@ import { NODE_H, V_GAP, PADDING, - type FlowNode, - type FlowEdge, + type FlowDesignerNode, + type FlowDesignerEdge, } from './flow-canvas-layout'; describe('isBackEdge', () => { @@ -58,14 +58,14 @@ describe('back-edge geometry', () => { }); describe('computeLayout — back-edges excluded from layering (ADR-0044)', () => { - const nodes: FlowNode[] = [ + const nodes: FlowDesignerNode[] = [ { id: 's', type: 'start' }, { id: 'a', type: 'approval' }, { id: 'w', type: 'wait' }, ]; it('keeps the back-edge target ABOVE the wait node it loops from', () => { - const edges: FlowEdge[] = [ + const edges: FlowDesignerEdge[] = [ { source: 's', target: 'a' }, { source: 'a', target: 'w', label: 'revise' }, { source: 'w', target: 'a', label: 'resubmit', type: 'back' }, @@ -83,7 +83,7 @@ describe('computeLayout — back-edges excluded from layering (ADR-0044)', () => // Same graph, but the closing edge is a normal connection: the longest-path // relaxation now pushes `a` below `w` (demonstrates why excluding back-edges // matters for a readable loop). - const edges: FlowEdge[] = [ + const edges: FlowDesignerEdge[] = [ { source: 's', target: 'a' }, { source: 'a', target: 'w', label: 'revise' }, { source: 'w', target: 'a', label: 'resubmit' }, @@ -139,10 +139,10 @@ describe('extractRegions (#2670 structured containers)', () => { // ── #2670 Phase 2: geometry-aware layered layout ───────────────────────────── /** heightOf making exactly one node tall — the expanded-container shape. */ -const tallOnly = (id: string, h: number) => (n: FlowNode) => (n.id === id ? h : NODE_H); +const tallOnly = (id: string, h: number) => (n: FlowDesignerNode) => (n.id === id ? h : NODE_H); describe('computeLayoutWithGeometry — constant-height invariance (the regression lock)', () => { - const GRAPHS: { name: string; nodes: FlowNode[]; edges: FlowEdge[] }[] = [ + const GRAPHS: { name: string; nodes: FlowDesignerNode[]; edges: FlowDesignerEdge[] }[] = [ { name: 'linear chain', nodes: [{ id: 's', type: 'start' }, { id: 'a', type: 'script' }, { id: 'e', type: 'end' }], @@ -208,7 +208,7 @@ describe('computeLayoutWithGeometry — constant-height invariance (the regressi }); describe('computeLayoutWithGeometry — cumulative variable-height offsets (#2670)', () => { - const chain: { nodes: FlowNode[]; edges: FlowEdge[] } = { + const chain: { nodes: FlowDesignerNode[]; edges: FlowDesignerEdge[] } = { nodes: [{ id: 's', type: 'start' }, { id: 'c', type: 'loop' }, { id: 'b', type: 'end' }], edges: [{ source: 's', target: 'c' }, { source: 'c', target: 'b' }], }; @@ -223,13 +223,13 @@ describe('computeLayoutWithGeometry — cumulative variable-height offsets (#267 }); it('same-layer siblings share y; the row is as tall as its tallest card', () => { - const nodes: FlowNode[] = [ + const nodes: FlowDesignerNode[] = [ { id: 's', type: 'start' }, { id: 'l', type: 'loop' }, { id: 'r', type: 'script' }, { id: 'j', type: 'end' }, ]; - const edges: FlowEdge[] = [ + const edges: FlowDesignerEdge[] = [ { source: 's', target: 'l' }, { source: 's', target: 'r' }, { source: 'l', target: 'j' }, @@ -246,12 +246,12 @@ describe('computeLayoutWithGeometry — cumulative variable-height offsets (#267 }); it('a manually-pinned tall node does not push auto rows (accepted-overlap rule)', () => { - const nodes: FlowNode[] = [ + const nodes: FlowDesignerNode[] = [ { id: 's', type: 'start' }, { id: 'pin', type: 'loop', ui: { x: 400, y: 10 } }, { id: 'e', type: 'end' }, ]; - const edges: FlowEdge[] = [{ source: 's', target: 'pin' }, { source: 'pin', target: 'e' }]; + const edges: FlowDesignerEdge[] = [{ source: 's', target: 'pin' }, { source: 'pin', target: 'e' }]; const tall = computeLayoutWithGeometry(nodes, edges, tallOnly('pin', 300)); const constant = computeLayoutWithGeometry(nodes, edges); expect(tall.positions.get('s')).toEqual(constant.positions.get('s')); diff --git a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.ts b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.ts index 1d920d2164..606cf48f87 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.ts @@ -17,7 +17,41 @@ export interface FlowNodeUI { y?: number; } -export interface FlowNode { +/** + * A flow node **as the designer canvas holds it mid-edit**. + * + * Renamed from `FlowNode`, which `@objectstack/spec/automation` owns + * (objectstack#4115). This is deliberately NOT that type, and the difference is + * not drift — it is a layer difference of the kind objectui#3090 named: the + * spec's `FlowNode` is a COMPLETE authored node (`label` required), while a + * canvas holds nodes the user has dropped but not finished. A freshly dropped + * node has no label yet; typing it as the spec's would make the editor's own + * intermediate state unrepresentable. + * + * Not to be confused with `@objectstack/spec/studio`'s `FlowCanvasNode` either + * — despite the name, that is the pure visual overlay + * (`{ nodeId, x, y, collapsed, width, fillColor, … }`), keyed BY node id rather + * than being the node. + * + * The `[k: string]: unknown` index signature is load-bearing: the canvas + * round-trips node properties it does not itself understand + * (`connectorConfig`, `inputSchema`, `waitEventConfig`, `boundaryConfig`, …), + * and dropping them on save would be data loss. It also means the compiler + * cannot compare this against the spec's node — an index signature absorbs + * every missing member (objectstack#4075) — which is precisely why the NAME had + * to stop claiming they are the same thing. + * + * KNOWN DIVERGENCE, left alone deliberately: this designer persists geometry as + * `ui: { x, y }` while the spec already models it twice + * (`FlowNode.position`, and `FlowCanvasNode`). Three spellings of one concept + * is a real defect, but reconciling it changes what gets written to metadata — + * a behaviour change that does not belong in a symbol burn-down. See the PR + * description; filed as a follow-up. + * + * `__tests__/spec-symbol-parity.test.ts` pins that the spec owns neither + * `FlowDesignerNode` nor `FlowDesignerEdge`. + */ +export interface FlowDesignerNode { id: string; type: string; label?: string; @@ -27,7 +61,22 @@ export interface FlowNode { [k: string]: unknown; } -export interface FlowEdge { +/** + * A flow edge as the designer canvas holds it mid-edit — see + * {@link FlowDesignerNode} for why this is not `@objectstack/spec/automation`'s + * `FlowEdge`. + * + * Two concrete reasons it cannot simply BE the spec's edge: an edge being drawn + * has no `id` until it is committed (the spec requires one), and this + * `condition` shape (`string | { source?: string }`) omits the ADR-0089 + * expression envelope's REQUIRED `dialect` discriminant. + * + * That second one is a genuine finding rather than a layering difference: the + * inspector can build a condition object the server's own `FlowEdgeSchema` + * would reject. Recorded as a follow-up rather than fixed here, because + * changing the emitted envelope is a behaviour change — see the PR description. + */ +export interface FlowDesignerEdge { id?: string; source: string; target: string; @@ -56,7 +105,7 @@ function isFiniteNum(v: unknown): v is number { } /** A node carries a persisted manual position when both x and y are finite. */ -export function hasManualPosition(node: FlowNode): boolean { +export function hasManualPosition(node: FlowDesignerNode): boolean { return isFiniteNum(node.ui?.x) && isFiniteNum(node.ui?.y); } @@ -69,16 +118,16 @@ export interface LabeledRegion { key: string; /** Header shown above the region — `undefined` for a loop body (no header). */ label?: string; - nodes: FlowNode[]; - edges: FlowEdge[]; + nodes: FlowDesignerNode[]; + edges: FlowDesignerEdge[]; } /** Coerce a config value to a region iff it is a non-empty `{ nodes, edges }`. */ -function asRegion(v: unknown): { nodes: FlowNode[]; edges: FlowEdge[] } | null { +function asRegion(v: unknown): { nodes: FlowDesignerNode[]; edges: FlowDesignerEdge[] } | null { if (!v || typeof v !== 'object') return null; const r = v as { nodes?: unknown; edges?: unknown }; if (!Array.isArray(r.nodes) || r.nodes.length === 0) return null; - return { nodes: r.nodes as FlowNode[], edges: Array.isArray(r.edges) ? (r.edges as FlowEdge[]) : [] }; + return { nodes: r.nodes as FlowDesignerNode[], edges: Array.isArray(r.edges) ? (r.edges as FlowDesignerEdge[]) : [] }; } /** @@ -90,7 +139,7 @@ function asRegion(v: unknown): { nodes: FlowNode[]; edges: FlowEdge[] } | null { * cards. The designer renders the returned regions read-only, nested under the * container. */ -export function extractRegions(node: FlowNode): LabeledRegion[] { +export function extractRegions(node: FlowDesignerNode): LabeledRegion[] { const cfg = (node.config ?? {}) as Record; switch (node.type) { case 'loop': { @@ -153,9 +202,9 @@ export interface FlowLayoutGeometry { * limitation: the author can drag it clear. */ export function computeLayoutWithGeometry( - nodes: FlowNode[], - edges: FlowEdge[], - heightOf: (node: FlowNode) => number = () => NODE_H, + nodes: FlowDesignerNode[], + edges: FlowDesignerEdge[], + heightOf: (node: FlowDesignerNode) => number = () => NODE_H, ): FlowLayoutGeometry { const positions = new Map(); const heights = new Map(nodes.map((n) => [n.id, heightOf(n)])); @@ -294,7 +343,7 @@ export function computeLayoutWithGeometry( * Back-compat positions-only view of {@link computeLayoutWithGeometry} with * every card at the constant {@link NODE_H} — exactly the historical layout. */ -export function computeLayout(nodes: FlowNode[], edges: FlowEdge[]): Map { +export function computeLayout(nodes: FlowDesignerNode[], edges: FlowDesignerEdge[]): Map { return computeLayoutWithGeometry(nodes, edges).positions; } @@ -341,7 +390,7 @@ export function edgeMidpoint(from: Point, to: Point): Point { } /** True for an ADR-0044 declared back-edge (a revise/rework loop's return). */ -export function isBackEdge(edge: Pick): boolean { +export function isBackEdge(edge: Pick): boolean { return edge.type === 'back'; } @@ -384,12 +433,12 @@ export function backEdgeLabelAnchor(from: Point, to: Point): Point { * consistent across them. Editing label/condition/isDefault never changes the * key (source/target/index are untouched), so a selection survives edits. */ -export function edgeKey(edge: FlowEdge, index: number): string { +export function edgeKey(edge: FlowDesignerEdge, index: number): string { return edge.id || `${edge.source}->${edge.target}#${index}`; } /** Human-readable condition text for an edge's optional guard. */ -export function conditionText(c: FlowEdge['condition']): string | undefined { +export function conditionText(c: FlowDesignerEdge['condition']): string | undefined { if (!c) return undefined; if (typeof c === 'string') return c; if (typeof c === 'object' && typeof c.source === 'string') return c.source; diff --git a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx index c070587aa3..426e9e0cc2 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx @@ -50,7 +50,7 @@ import { CommandList, } from '@object-ui/components'; import { t as tr, translateNodeLabel, translateNodeHint } from '../i18n'; -import { NODE_W, NODE_H, type Point, type LabeledRegion, type FlowNode } from './flow-canvas-layout'; +import { NODE_W, NODE_H, type Point, type LabeledRegion, type FlowDesignerNode } from './flow-canvas-layout'; import { FlowRegionView } from './flow-region-view'; import { EXPANDED_REGION_MAX_W, NODE_REGION_GAP, REGION_PANEL_PAD } from './flow-region-metrics'; import { useFlowPaletteRecents } from '../../../context/FlowPaletteRecentsProvider'; @@ -457,7 +457,7 @@ export interface NodeCardProps { * #2670 Phase 3: select a nested node in the tray, tagged with its region * key. Absent → the tray stays read-only (Phase 2 behavior). */ - onSelectNestedNode?: (regionKey: string, node: FlowNode) => void; + onSelectNestedNode?: (regionKey: string, node: FlowDesignerNode) => void; /** * #2670: the card's rendered height from the layout geometry — the SAME * number that positioned every card below this one, so the DOM can never diff --git a/packages/app-shell/src/views/metadata-admin/previews/flow-problems.ts b/packages/app-shell/src/views/metadata-admin/previews/flow-problems.ts index a75d00cba0..1a23636b85 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/flow-problems.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/flow-problems.ts @@ -20,7 +20,7 @@ import { validateFlowDraft } from './simulator/flow-sim-validate'; import type { Diagnostic, DiagnosticLevel, SimEdge, SimNode } from './simulator/flow-sim-types'; -import { edgeKey, type FlowEdge, type FlowNode } from './flow-canvas-layout'; +import { edgeKey, type FlowDesignerEdge, type FlowDesignerNode } from './flow-canvas-layout'; import { flowExpressionProblems } from './flow-expr-problems'; /** What a problem points at on the canvas — drives badge placement + reveal. */ @@ -62,7 +62,7 @@ export function edgeProblemKey(source: string, target: string): string { } /** Resolve an edge's selection key (`edgeKey`) from its endpoints. */ -function resolveEdgeKey(edges: FlowEdge[], source: string, target: string): string { +function resolveEdgeKey(edges: FlowDesignerEdge[], source: string, target: string): string { const idx = edges.findIndex((e) => e.source === source && e.target === target); return idx >= 0 ? edgeKey(edges[idx], idx) : `${source}->${target}#-1`; } @@ -89,7 +89,7 @@ interface StructuralMapping { * the author marks as a back-edge to resolve it — but flags EVERY hop (nodes + * edges) for the red error highlight so the whole loop reads as the problem. */ -function structuralMapping(diag: Diagnostic, edges: FlowEdge[]): StructuralMapping { +function structuralMapping(diag: Diagnostic, edges: FlowDesignerEdge[]): StructuralMapping { if (diag.edge) { const { source, target } = diag.edge; return { target: { kind: 'edge', source, target, edgeKey: resolveEdgeKey(edges, source, target) } }; @@ -114,7 +114,7 @@ function structuralMapping(diag: Diagnostic, edges: FlowEdge[]): StructuralMappi } /** Map a server diagnostic's JSON path onto a node/edge/flow target. */ -function serverTarget(path: ServerDiagnostic['path'], nodes: FlowNode[], edges: FlowEdge[]): FlowProblemTarget { +function serverTarget(path: ServerDiagnostic['path'], nodes: FlowDesignerNode[], edges: FlowDesignerEdge[]): FlowProblemTarget { const segs = pathSegments(path); if (segs.length >= 2 && typeof segs[1] === 'number') { const idx = segs[1]; @@ -135,8 +135,8 @@ function targetKey(t: FlowProblemTarget): string { } export interface BuildFlowProblemsArgs { - nodes: FlowNode[]; - edges: FlowEdge[]; + nodes: FlowDesignerNode[]; + edges: FlowDesignerEdge[]; /** Server `_diagnostics`, flattened to a severity-tagged, path-keyed list. */ serverDiagnostics?: ServerDiagnostic[]; /** Declared flow variables — needed to resolve scope for the expression check. */ diff --git a/packages/app-shell/src/views/metadata-admin/previews/flow-region-view.tsx b/packages/app-shell/src/views/metadata-admin/previews/flow-region-view.tsx index 9b436c5809..032804518e 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/flow-region-view.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/flow-region-view.tsx @@ -27,7 +27,7 @@ import { backEdgePath, NODE_W, NODE_H, - type FlowNode, + type FlowDesignerNode, type LabeledRegion, } from './flow-canvas-layout'; import { NodeTypeIcon, nodeTone } from './flow-canvas-parts'; @@ -67,7 +67,7 @@ function RegionNode({ selected, onSelect, }: { - node: FlowNode; + node: FlowDesignerNode; x: number; y: number; selected?: boolean; @@ -130,7 +130,7 @@ function RegionCanvas({ region: LabeledRegion; maxWidth: number; selectedNodeId?: string | null; - onSelectNode?: (node: FlowNode) => void; + onSelectNode?: (node: FlowDesignerNode) => void; }) { const layout = React.useMemo(() => computeLayout(region.nodes, region.edges), [region.nodes, region.edges]); const { width, height } = React.useMemo(() => diagramSize(layout), [layout]); @@ -194,7 +194,7 @@ export function FlowRegionView({ /** The selected nested node, scoped to its region — highlighted with a ring. */ selected?: { regionKey: string; nodeId: string } | null; /** Selecting a nested node, tagged with its region key (for the container path). */ - onSelectNode?: (regionKey: string, node: FlowNode) => void; + onSelectNode?: (regionKey: string, node: FlowDesignerNode) => void; /** UI locale for the region header labels. */ locale?: string; }) { diff --git a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts index 7ea7b5f17d..fabeb81e95 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts @@ -137,29 +137,43 @@ export function toFieldNameLoose(raw: string): string { } /** - * A declared field group (a.k.a. "section"). Lives at the object's - * top level as `draft.fieldGroups`; individual fields opt into a group - * via `Field.group === FieldGroup.key`. + * A declared field group (a.k.a. "section"). Lives at the object's top level as + * `draft.fieldGroups`; individual fields opt into a group via + * `Field.group === ObjectFieldGroup.key`. + * + * Named `ObjectFieldGroup`, which is the spec's own name for this shape + * (`@objectstack/spec/data`, the `ObjectFieldGroupSchema` family), and + * re-exported from there rather than re-declared. + * + * It used to be called `FieldGroup` — and `@objectstack/spec/studio` exports a + * DIFFERENT `FieldGroup`: the Studio field-editor's own group config + * (`{ key, label, icon?, defaultExpanded, order }`), which has no `collapse` + * and adds `order`. The local doc comment nonetheless claimed `description` and + * `collapse` were "spec-defined" — true of `ObjectFieldGroup`, false of the + * `FieldGroup` the name actually resolved to. That is objectstack#4115's + * planted-premise failure exactly: a correct sentence filed under a name that + * points somewhere else. Key-for-key this is `ObjectFieldGroup`, so the fix is + * to say so. + * + * Collapse semantics (unchanged, now single-sourced): `'none'` → not + * collapsible; `'expanded'` → collapsible, open by default; `'collapsed'` → + * collapsible, closed by default; `collapsible` / `collapsed` / + * `defaultExpanded` are the legacy boolean aliases the shared + * `deriveFieldGroupLayout` still normalizes. + * + * Derived from `z.input`, NOT the exported `ObjectFieldGroup` type (which is + * `z.infer`, i.e. the OUTPUT side). The distinction is load-bearing here and is + * the zod-specific trap the derivation guard's header warns about: `collapse` + * carries `.default('none')`, so it is OPTIONAL to author and REQUIRED after + * parsing. This designer is on the authoring side — `addGroup` creates + * `{ key, label }` and lets the default apply — so the output type would make + * the editor's own new-group shape unrepresentable. Using `z.infer` here + * type-checks against a value nobody in this module ever holds. */ -export interface FieldGroup { - key: string; - label: string; - /** Optional group icon (Lucide name). Spec-defined; preserved on round-trip. */ - icon?: string; - /** Optional group description. Spec-defined; preserved on round-trip. */ - description?: string; - /** - * Collapse behaviour — the spec-canonical control the form renderer consumes - * (via `@objectstack/spec`'s `deriveFieldGroupLayout`): `'none'` → not - * collapsible; `'expanded'` → collapsible, open by default; `'collapsed'` → - * collapsible, closed by default. - */ - collapse?: 'none' | 'expanded' | 'collapsed'; - /** Legacy boolean aliases (still normalized by the shared derivation). */ - collapsible?: boolean; - collapsed?: boolean; - defaultExpanded?: boolean; -} +export type ObjectFieldGroup = z.input; + +import type { z } from 'zod'; +import type { ObjectFieldGroupSchema } from '@objectstack/spec/data'; /** * Read `draft.fieldGroups` into a normalized, well-typed list. Unknown/extra @@ -167,7 +181,7 @@ export interface FieldGroup { * read-modify-write round-trip (rename/reorder/inspector edit) never silently * drops a property the source set — only `key`/`label` are coerced to strings. */ -export function readGroups(fieldGroupsInput: unknown): FieldGroup[] { +export function readGroups(fieldGroupsInput: unknown): ObjectFieldGroup[] { if (!Array.isArray(fieldGroupsInput)) return []; return fieldGroupsInput .filter((g): g is Record => !!g && typeof g === 'object') @@ -175,7 +189,7 @@ export function readGroups(fieldGroupsInput: unknown): FieldGroup[] { ...g, key: typeof g.key === 'string' ? g.key : '', label: typeof g.label === 'string' ? g.label : '', - }) as FieldGroup) + }) as ObjectFieldGroup) .filter((g) => g.key); } @@ -194,14 +208,14 @@ export function genGroupKey(label: string, existing: string[]): string { } /** Append a new group with a unique key derived from `label`. */ -export function addGroup(groups: FieldGroup[], label: string): FieldGroup[] { +export function addGroup(groups: ObjectFieldGroup[], label: string): ObjectFieldGroup[] { const clean = label.trim() || 'New section'; const key = genGroupKey(clean, groups.map((g) => g.key)); return [...groups, { key, label: clean }]; } /** Rename a group's label in place (key is stable). */ -export function renameGroup(groups: FieldGroup[], key: string, label: string): FieldGroup[] { +export function renameGroup(groups: ObjectFieldGroup[], key: string, label: string): ObjectFieldGroup[] { const clean = label.trim(); if (!clean) return groups; return groups.map((g) => (g.key === key ? { ...g, label: clean } : g)); @@ -213,10 +227,10 @@ export function renameGroup(groups: FieldGroup[], key: string, label: string): F * leaves no stale key behind) rather than persisting an explicit `undefined`. */ export function updateGroup( - groups: FieldGroup[], + groups: ObjectFieldGroup[], key: string, - patch: Partial, -): FieldGroup[] { + patch: Partial, +): ObjectFieldGroup[] { return groups.map((g) => { if (g.key !== key) return g; const next = { ...g } as Record; @@ -224,17 +238,17 @@ export function updateGroup( if (v === undefined) delete next[k]; else next[k] = v; } - return next as unknown as FieldGroup; + return next as unknown as ObjectFieldGroup; }); } /** Remove a group declaration (callers should also clear members' `group`). */ -export function removeGroup(groups: FieldGroup[], key: string): FieldGroup[] { +export function removeGroup(groups: ObjectFieldGroup[], key: string): ObjectFieldGroup[] { return groups.filter((g) => g.key !== key); } /** Move a group one slot up (-1) or down (+1), clamped to bounds. */ -export function moveGroup(groups: FieldGroup[], key: string, dir: -1 | 1): FieldGroup[] { +export function moveGroup(groups: ObjectFieldGroup[], key: string, dir: -1 | 1): ObjectFieldGroup[] { const idx = groups.findIndex((g) => g.key === key); if (idx < 0) return groups; const to = idx + dir; diff --git a/packages/app-shell/src/views/metadata-admin/view-item-normalize.ts b/packages/app-shell/src/views/metadata-admin/view-item-normalize.ts index 53265a495a..9ec41f7668 100644 --- a/packages/app-shell/src/views/metadata-admin/view-item-normalize.ts +++ b/packages/app-shell/src/views/metadata-admin/view-item-normalize.ts @@ -40,12 +40,15 @@ function isPlainObject(v: unknown): v is Record { * Studio expands every such container into independent `.` * ViewItems (including the defaults), so the container row is fully * redundant in the metadata list and should be hidden there. + * + * Re-exported from `@objectstack/spec`, which owns this predicate and pairs it + * with `expandViewContainer` — the expansion Studio performs. What used to sit + * here was a verbatim, line-for-line copy of the spec's implementation + * (objectstack#4115): the two agreed today only because nobody had touched + * either, and a copy of a predicate is the one thing that cannot be caught by + * comparing values. */ -export function isAggregatedViewContainer(item: unknown): boolean { - if (!isPlainObject(item)) return false; - if (item.viewKind) return false; // already an independent ViewItem - return Boolean(item.list || item.form || item.listViews || item.formViews); -} +export { isAggregatedViewContainer } from '@objectstack/spec'; /** * Derive the display "type" of a view list row. Expanded ViewItems keep diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 94c935d154..319aae5e2b 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -100,7 +100,7 @@ import { CreateItemDialog } from './CreateItemDialog'; import { CreatePackageDialog, PackageDetailSheet, - type InstalledPackage, + type InstalledPackageRow, } from '../metadata-admin/PackagesPage'; import { ObjectFormDesigner } from './ObjectFormDesigner'; import { ObjectGroupInspector } from './ObjectGroupInspector'; @@ -262,7 +262,7 @@ function PackageSwitcher({ const [open, setOpen] = React.useState(false); const [pkgs, setPkgs] = React.useState(null); const [createOpen, setCreateOpen] = React.useState(false); - const [manage, setManage] = React.useState(null); + const [manage, setManage] = React.useState(null); const [manageOpen, setManageOpen] = React.useState(false); const [manageBusy, setManageBusy] = React.useState(false); @@ -294,7 +294,7 @@ function PackageSwitcher({ // Open the standard detail/management sheet for a package — fetch its full // installed record (manifest + status) first, since the switcher only holds // the trimmed {id,name,writable} view. - const fetchFullPackage = React.useCallback(async (id: string): Promise => { + const fetchFullPackage = React.useCallback(async (id: string): Promise => { const res = await fetch('/api/v1/packages', { credentials: 'include', headers: { Accept: 'application/json' }, @@ -303,7 +303,7 @@ function PackageSwitcher({ const data = (await res.json()) as unknown; const root = (data as { data?: unknown })?.data ?? data; const list = (Array.isArray(root) ? root : ((root as { packages?: unknown[] })?.packages ?? [])) as Array< - InstalledPackage & { id?: string } + InstalledPackageRow & { id?: string } >; return list.find((p) => (p?.manifest?.id ?? p?.id) === id) ?? null; }, []); diff --git a/scripts/check-spec-symbol-derivation.mjs b/scripts/check-spec-symbol-derivation.mjs index b854efdbea..b133d5b4a1 100644 --- a/scripts/check-spec-symbol-derivation.mjs +++ b/scripts/check-spec-symbol-derivation.mjs @@ -205,36 +205,6 @@ const DEBT = { "WidgetManifest", "WidgetSource", ], - "@object-ui/app-shell": [ - "ConversationSummary", - "DecisionOutputDef", - "ExplainDecision", - "ExplainLayer", - "ExplainMatchedRule", - "ExplainRecordAttribution", - "ExternalCatalog", - "ExternalColumn", - "ExternalTable", - "FieldGroup", - "FieldInput", - "FilterCondition", - "FlowEdge", - "FlowNode", - "GenerateDraftOpts", - "InstalledPackage", - "ObjectDraft", - "PackageManifest", - "PageHeaderProps", - "PluginPermissions", - "RemoteTable", - "RuntimeConfig", - "SchemaDiffEntry", - "SchemaDiffEntryKind", - "SchemaValidationResult", - "ScreenFieldSpec", - "ScreenSpec", - "isAggregatedViewContainer", - ], "@object-ui/core": [ "ActionHandler", "CONTEXT_TOKENS",