Skip to content

Commit 0fc43ba

Browse files
committed
2 parents bd4fb05 + 3d04e06 commit 0fc43ba

3 files changed

Lines changed: 193 additions & 0 deletions

File tree

.changeset/spec-create-seeds.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
Add authoritative per-type create seeds (root-cause for the "designer shape ≠ spec" family)
6+
7+
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`.)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
getMetadataCreateSeed,
6+
listMetadataCreateSeedTypes,
7+
} from './metadata-create-seeds';
8+
import {
9+
getMetadataTypeSchema,
10+
listMetadataTypeSchemaTypes,
11+
} from './metadata-type-schemas';
12+
13+
/**
14+
* THE canonical guard for the "designer create shape ≠ spec required" family:
15+
* every authoritative minimal create seed MUST validate against its type's
16+
* spec schema. If a schema tightens a requirement (e.g. action's `body`,
17+
* dashboard's old `layout`), the matching seed fails here — right next to the
18+
* schema — instead of 422-ing only when a user clicks Save in Studio.
19+
*/
20+
describe('metadata create seeds validate against their spec schemas', () => {
21+
for (const type of listMetadataCreateSeedTypes()) {
22+
it(`${type}: minimal create seed is spec-valid`, () => {
23+
const schema = getMetadataTypeSchema(type);
24+
expect(schema, `no schema registered for seeded type '${type}'`).toBeDefined();
25+
const seed = getMetadataCreateSeed(type);
26+
const result = schema!.safeParse(seed);
27+
expect(
28+
result.success,
29+
result.success ? '' : `seed for '${type}' rejected: ${JSON.stringify(result.error.issues)}`,
30+
).toBe(true);
31+
});
32+
}
33+
34+
it('sanity: seeds the core Studio-designer types', () => {
35+
const seeded = new Set(listMetadataCreateSeedTypes());
36+
for (const t of ['dashboard', 'action', 'page', 'view', 'flow', 'validation', 'hook', 'dataset', 'object']) {
37+
expect(seeded.has(t), `core type '${t}' has no create seed`).toBe(true);
38+
}
39+
});
40+
41+
it('getMetadataCreateSeed returns a fresh clone (callers may mutate)', () => {
42+
const a = getMetadataCreateSeed('dashboard') as { widgets: unknown[] };
43+
const b = getMetadataCreateSeed('dashboard') as { widgets: unknown[] };
44+
expect(a).not.toBe(b);
45+
a.widgets.push({});
46+
expect((getMetadataCreateSeed('dashboard') as { widgets: unknown[] }).widgets).toHaveLength(0);
47+
});
48+
49+
it('surfaces schema-backed authorable types still missing a seed (no silent cap)', () => {
50+
// Types that have a runtime-editable schema but no create seed yet. Canvas-
51+
// create types (report builds its dataset on the canvas) and code-only /
52+
// identity types legitimately have no static minimal create literal.
53+
const KNOWN_UNSEEDED = new Set([
54+
'report', // canvas-create: dataset/measures picked interactively
55+
'app', 'field', 'seed', 'job', 'datasource', 'translation', 'doc', 'book',
56+
'permission', 'profile', 'role', 'agent', 'tool', 'skill', 'email_template',
57+
]);
58+
const seeded = new Set(listMetadataCreateSeedTypes());
59+
const missing = listMetadataTypeSchemaTypes().filter((t) => !seeded.has(t) && !KNOWN_UNSEEDED.has(t));
60+
// eslint-disable-next-line no-console
61+
if (missing.length) console.log(`[create-seeds] schema'd types still needing a seed: ${missing.join(', ')}`);
62+
expect(missing, `unaccounted schema'd types without a seed: ${missing.join(', ')}`).toEqual([]);
63+
});
64+
});
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Metadata Type → minimal **valid create seed** (single source of truth).
5+
*
6+
* The recurring "designer creates a minimal item that fails spec validation on
7+
* save" family (a new dashboard lacked `layout`; a new `script` action lacks
8+
* `body`; a report carried stale `objectName`/`columns`) has one root cause:
9+
* the create-form's default shape was invented client-side (objectui's
10+
* `createDefaults`) and drifted from the spec's required fields, with nothing
11+
* validating the two against each other.
12+
*
13+
* This registry is the authoritative minimal shape a freshly-created item of
14+
* each type should carry — co-located with the schemas in `packages/spec` and
15+
* asserted valid by `metadata-create-seeds.test.ts`. When a schema tightens a
16+
* requirement, this seed (and the test) is right next to it, so the create
17+
* path can't silently break. Consumers (the Studio designer via `/meta/types`,
18+
* the CLI, API clients) should derive their create defaults from here instead
19+
* of re-inventing them.
20+
*
21+
* Each seed is a COMPLETE minimal valid object including placeholder identity
22+
* (`name`/`label`/object binding); a create flow overrides those from user
23+
* input. Structural defaults (empty collections, the required `type`/`body`,
24+
* …) are the part that matters and must not drift.
25+
*/
26+
27+
import type { MetadataType } from './metadata-plugin.zod';
28+
29+
const PLACEHOLDER_OBJECT = 'example_object';
30+
31+
/**
32+
* Built-in minimal create seeds. Keyed by metadata type; every entry is
33+
* validated against its `metadata-type-schemas` schema by the test.
34+
*
35+
* Canvas-create types whose full shape is built INTERACTIVELY (e.g. `report`
36+
* picks its dataset/measures on the canvas, `object` adds fields on the canvas)
37+
* are intentionally absent — their minimal shape isn't a static literal. The
38+
* test documents these exclusions.
39+
*/
40+
const BUILTIN_METADATA_CREATE_SEEDS: Partial<Record<MetadataType, unknown>> = {
41+
dashboard: {
42+
name: 'new_dashboard',
43+
label: 'New Dashboard',
44+
widgets: [],
45+
},
46+
action: {
47+
name: 'new_action',
48+
label: 'New Action',
49+
// `type` defaults to 'script', which the spec requires to carry an
50+
// executable body or target — seed a no-op L2 body so create round-trips.
51+
type: 'script',
52+
body: { language: 'js', source: 'return { success: true };' },
53+
},
54+
page: {
55+
name: 'new_page',
56+
label: 'New Page',
57+
object: PLACEHOLDER_OBJECT,
58+
type: 'list',
59+
kind: 'full',
60+
regions: [],
61+
},
62+
view: {
63+
name: `${PLACEHOLDER_OBJECT}.new_view`,
64+
object: PLACEHOLDER_OBJECT,
65+
viewKind: 'list',
66+
label: 'New View',
67+
config: { type: 'grid', columns: [], data: { provider: 'object', object: PLACEHOLDER_OBJECT } },
68+
},
69+
flow: {
70+
name: 'new_flow',
71+
label: 'New Flow',
72+
type: 'autolaunched',
73+
nodes: [],
74+
edges: [],
75+
},
76+
validation: {
77+
name: 'new_validation',
78+
label: 'New Validation',
79+
message: 'This record is invalid.',
80+
type: 'script',
81+
active: true,
82+
events: ['insert', 'update'],
83+
priority: 10,
84+
severity: 'error',
85+
condition: 'false',
86+
},
87+
hook: {
88+
name: 'new_hook',
89+
label: 'New Hook',
90+
object: PLACEHOLDER_OBJECT,
91+
events: [],
92+
},
93+
dataset: {
94+
name: 'new_dataset',
95+
label: 'New Dataset',
96+
object: PLACEHOLDER_OBJECT,
97+
dimensions: [],
98+
// A dataset needs at least one measure to be useful; seed a count.
99+
measures: [{ name: 'count', label: 'Count', aggregate: 'count' }],
100+
},
101+
object: {
102+
name: 'new_object',
103+
label: 'New Object',
104+
pluralLabel: 'New Objects',
105+
fields: {},
106+
},
107+
};
108+
109+
/**
110+
* Return the authoritative minimal create seed for a metadata type, or
111+
* `undefined` when none is registered (caller falls back to `{}`). The
112+
* returned object is a fresh deep clone so callers may mutate it freely.
113+
*/
114+
export function getMetadataCreateSeed(type: string): unknown | undefined {
115+
const seed = BUILTIN_METADATA_CREATE_SEEDS[type as MetadataType];
116+
return seed === undefined ? undefined : structuredClone(seed);
117+
}
118+
119+
/** Snapshot of every type that has a built-in create seed. */
120+
export function listMetadataCreateSeedTypes(): string[] {
121+
return Object.keys(BUILTIN_METADATA_CREATE_SEEDS).sort();
122+
}

0 commit comments

Comments
 (0)