diff --git a/.changeset/spec-create-seeds.md b/.changeset/spec-create-seeds.md new file mode 100644 index 0000000000..adaed62b8b --- /dev/null +++ b/.changeset/spec-create-seeds.md @@ -0,0 +1,7 @@ +--- +'@objectstack/spec': minor +--- + +Add authoritative per-type create seeds (root-cause for the "designer shape ≠ spec" family) + +New `metadata-create-seeds.ts`: a single source of truth for the minimal valid create shape of each metadata type (`getMetadataCreateSeed(type)`), co-located with the schemas and asserted valid against each type's schema by `metadata-create-seeds.test.ts`. This anchors the create-form's default shape to the spec so it can't drift — the root cause of the recurring family where a freshly-created item (dashboard without `layout`, script action without `body`, report with stale `objectName`/`columns`) failed validation on save (422) yet passed every other gate. Seeds the 9 core Studio-designer types (dashboard, action, page, view, flow, validation, hook, dataset, object); the test surfaces remaining schema-backed types still needing a seed. (Follow-up: expose `createSeed` via `/meta/types` so the Studio designer consumes it instead of hardcoding `createDefaults`.) diff --git a/packages/spec/src/kernel/metadata-create-seeds.test.ts b/packages/spec/src/kernel/metadata-create-seeds.test.ts new file mode 100644 index 0000000000..dde23febaf --- /dev/null +++ b/packages/spec/src/kernel/metadata-create-seeds.test.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + getMetadataCreateSeed, + listMetadataCreateSeedTypes, +} from './metadata-create-seeds'; +import { + getMetadataTypeSchema, + listMetadataTypeSchemaTypes, +} from './metadata-type-schemas'; + +/** + * THE canonical guard for the "designer create shape ≠ spec required" family: + * every authoritative minimal create seed MUST validate against its type's + * spec schema. If a schema tightens a requirement (e.g. action's `body`, + * dashboard's old `layout`), the matching seed fails here — right next to the + * schema — instead of 422-ing only when a user clicks Save in Studio. + */ +describe('metadata create seeds validate against their spec schemas', () => { + for (const type of listMetadataCreateSeedTypes()) { + it(`${type}: minimal create seed is spec-valid`, () => { + const schema = getMetadataTypeSchema(type); + expect(schema, `no schema registered for seeded type '${type}'`).toBeDefined(); + const seed = getMetadataCreateSeed(type); + const result = schema!.safeParse(seed); + expect( + result.success, + result.success ? '' : `seed for '${type}' rejected: ${JSON.stringify(result.error.issues)}`, + ).toBe(true); + }); + } + + it('sanity: seeds the core Studio-designer types', () => { + const seeded = new Set(listMetadataCreateSeedTypes()); + for (const t of ['dashboard', 'action', 'page', 'view', 'flow', 'validation', 'hook', 'dataset', 'object']) { + expect(seeded.has(t), `core type '${t}' has no create seed`).toBe(true); + } + }); + + it('getMetadataCreateSeed returns a fresh clone (callers may mutate)', () => { + const a = getMetadataCreateSeed('dashboard') as { widgets: unknown[] }; + const b = getMetadataCreateSeed('dashboard') as { widgets: unknown[] }; + expect(a).not.toBe(b); + a.widgets.push({}); + expect((getMetadataCreateSeed('dashboard') as { widgets: unknown[] }).widgets).toHaveLength(0); + }); + + it('surfaces schema-backed authorable types still missing a seed (no silent cap)', () => { + // Types that have a runtime-editable schema but no create seed yet. Canvas- + // create types (report builds its dataset on the canvas) and code-only / + // identity types legitimately have no static minimal create literal. + const KNOWN_UNSEEDED = new Set([ + 'report', // canvas-create: dataset/measures picked interactively + 'app', 'field', 'seed', 'job', 'datasource', 'translation', 'doc', 'book', + 'permission', 'profile', 'role', 'agent', 'tool', 'skill', 'email_template', + ]); + const seeded = new Set(listMetadataCreateSeedTypes()); + const missing = listMetadataTypeSchemaTypes().filter((t) => !seeded.has(t) && !KNOWN_UNSEEDED.has(t)); + // eslint-disable-next-line no-console + if (missing.length) console.log(`[create-seeds] schema'd types still needing a seed: ${missing.join(', ')}`); + expect(missing, `unaccounted schema'd types without a seed: ${missing.join(', ')}`).toEqual([]); + }); +}); diff --git a/packages/spec/src/kernel/metadata-create-seeds.ts b/packages/spec/src/kernel/metadata-create-seeds.ts new file mode 100644 index 0000000000..23a6954533 --- /dev/null +++ b/packages/spec/src/kernel/metadata-create-seeds.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Metadata Type → minimal **valid create seed** (single source of truth). + * + * The recurring "designer creates a minimal item that fails spec validation on + * save" family (a new dashboard lacked `layout`; a new `script` action lacks + * `body`; a report carried stale `objectName`/`columns`) has one root cause: + * the create-form's default shape was invented client-side (objectui's + * `createDefaults`) and drifted from the spec's required fields, with nothing + * validating the two against each other. + * + * This registry is the authoritative minimal shape a freshly-created item of + * each type should carry — co-located with the schemas in `packages/spec` and + * asserted valid by `metadata-create-seeds.test.ts`. When a schema tightens a + * requirement, this seed (and the test) is right next to it, so the create + * path can't silently break. Consumers (the Studio designer via `/meta/types`, + * the CLI, API clients) should derive their create defaults from here instead + * of re-inventing them. + * + * Each seed is a COMPLETE minimal valid object including placeholder identity + * (`name`/`label`/object binding); a create flow overrides those from user + * input. Structural defaults (empty collections, the required `type`/`body`, + * …) are the part that matters and must not drift. + */ + +import type { MetadataType } from './metadata-plugin.zod'; + +const PLACEHOLDER_OBJECT = 'example_object'; + +/** + * Built-in minimal create seeds. Keyed by metadata type; every entry is + * validated against its `metadata-type-schemas` schema by the test. + * + * Canvas-create types whose full shape is built INTERACTIVELY (e.g. `report` + * picks its dataset/measures on the canvas, `object` adds fields on the canvas) + * are intentionally absent — their minimal shape isn't a static literal. The + * test documents these exclusions. + */ +const BUILTIN_METADATA_CREATE_SEEDS: Partial> = { + dashboard: { + name: 'new_dashboard', + label: 'New Dashboard', + widgets: [], + }, + action: { + name: 'new_action', + label: 'New Action', + // `type` defaults to 'script', which the spec requires to carry an + // executable body or target — seed a no-op L2 body so create round-trips. + type: 'script', + body: { language: 'js', source: 'return { success: true };' }, + }, + page: { + name: 'new_page', + label: 'New Page', + object: PLACEHOLDER_OBJECT, + type: 'list', + kind: 'full', + regions: [], + }, + view: { + name: `${PLACEHOLDER_OBJECT}.new_view`, + object: PLACEHOLDER_OBJECT, + viewKind: 'list', + label: 'New View', + config: { type: 'grid', columns: [], data: { provider: 'object', object: PLACEHOLDER_OBJECT } }, + }, + flow: { + name: 'new_flow', + label: 'New Flow', + type: 'autolaunched', + nodes: [], + edges: [], + }, + validation: { + name: 'new_validation', + label: 'New Validation', + message: 'This record is invalid.', + type: 'script', + active: true, + events: ['insert', 'update'], + priority: 10, + severity: 'error', + condition: 'false', + }, + hook: { + name: 'new_hook', + label: 'New Hook', + object: PLACEHOLDER_OBJECT, + events: [], + }, + dataset: { + name: 'new_dataset', + label: 'New Dataset', + object: PLACEHOLDER_OBJECT, + dimensions: [], + // A dataset needs at least one measure to be useful; seed a count. + measures: [{ name: 'count', label: 'Count', aggregate: 'count' }], + }, + object: { + name: 'new_object', + label: 'New Object', + pluralLabel: 'New Objects', + fields: {}, + }, +}; + +/** + * Return the authoritative minimal create seed for a metadata type, or + * `undefined` when none is registered (caller falls back to `{}`). The + * returned object is a fresh deep clone so callers may mutate it freely. + */ +export function getMetadataCreateSeed(type: string): unknown | undefined { + const seed = BUILTIN_METADATA_CREATE_SEEDS[type as MetadataType]; + return seed === undefined ? undefined : structuredClone(seed); +} + +/** Snapshot of every type that has a built-in create seed. */ +export function listMetadataCreateSeedTypes(): string[] { + return Object.keys(BUILTIN_METADATA_CREATE_SEEDS).sort(); +}