Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/spec-create-seeds.md
Original file line number Diff line number Diff line change
@@ -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`.)
64 changes: 64 additions & 0 deletions packages/spec/src/kernel/metadata-create-seeds.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
122 changes: 122 additions & 0 deletions packages/spec/src/kernel/metadata-create-seeds.ts
Original file line number Diff line number Diff line change
@@ -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<Record<MetadataType, unknown>> = {
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();
}
Loading