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
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,9 @@ function MetadataResourceEditPageImpl({
if (!hasClientValidator(type)) return;
let cancelled = false;
const handle = window.setTimeout(() => {
void validateMetadataDraft(type, draft).then((res) => {
// Pass the live server schema so the client never flags fields the
// running server now treats as optional (cross-repo spec-skew root-cure).
void validateMetadataDraft(type, draft, entry?.schema as { required?: unknown } | undefined).then((res) => {
if (cancelled) return;
setIssues(res.issues);
});
Expand All @@ -378,7 +380,7 @@ function MetadataResourceEditPageImpl({
cancelled = true;
window.clearTimeout(handle);
};
}, [type, draft]);
}, [type, draft, entry?.schema]);
// Per-item draft pending publish (mode=draft saves land here).
// When non-null, the editor is "viewing the draft" and we surface
// Publish / Discard-draft actions.
Expand Down
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.

/**
* Cross-repo spec-skew root-cure: the client (bundled @objectstack/spec) must
* never be STRICTER than the running server. validateMetadataDraft suppresses
* "missing required field" errors for fields the server schema marks optional.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';

// Stub the bundled report schema to the STALE shape (objectName + columns
// required) so we can prove the suppression against a newer server schema.
vi.mock('@objectstack/spec/ui', () => ({
ReportSchema: {
safeParse: (v: any) => {
const issues: Array<{ path: (string | number)[]; message: string }> = [];
if (v?.objectName === undefined) issues.push({ path: ['objectName'], message: 'Required' });
if (v?.columns === undefined) issues.push({ path: ['columns'], message: 'Required' });
// a present-but-invalid field error that must NEVER be suppressed
if (v?.label === '') issues.push({ path: ['label'], message: 'Label must not be empty' });
return issues.length ? { success: false, error: { issues } } : { success: true };
},
},
}));

import { validateMetadataDraft } from './clientValidation';

beforeEach(() => vi.clearAllMocks());

// Server schema where objectName/columns are OPTIONAL (the dual-form, newer).
const serverSchema = { required: ['name', 'label'] };

describe('validateMetadataDraft — spec-skew suppression', () => {
it('suppresses stale "objectName/columns required" when the server marks them optional', async () => {
const draft = { name: 'rev', label: 'Revenue', dataset: 'sales', values: ['revenue'] };
const res = await validateMetadataDraft('report', draft, serverSchema);
expect(res.ok).toBe(true);
expect(res.issues).toHaveLength(0);
});

it('still flags a present-but-invalid field (never over-suppresses)', async () => {
const draft = { name: 'rev', label: '', dataset: 'sales', values: ['revenue'] };
const res = await validateMetadataDraft('report', draft, serverSchema);
expect(res.ok).toBe(false);
expect(res.issues.map((i) => i.path)).toContain('label');
// objectName/columns are still suppressed (absent + server-optional)
expect(res.issues.map((i) => i.path)).not.toContain('objectName');
});

it('without a server schema, keeps the legacy (strict bundled) behavior', async () => {
const draft = { name: 'rev', label: 'Revenue' }; // no objectName/columns
const res = await validateMetadataDraft('report', draft);
expect(res.ok).toBe(false);
expect(res.issues.map((i) => i.path)).toEqual(expect.arrayContaining(['objectName', 'columns']));
});

it('does not suppress a required field that is still required by the server', async () => {
// server requires label; bundled flags objectName(optional-on-server) + ... ;
// here label is present so no label issue, objectName suppressed → ok.
const draft = { name: 'rev', label: 'Revenue', dataset: 'sales', values: ['revenue'] };
const res = await validateMetadataDraft('report', draft, { required: ['name', 'label'] });
expect(res.ok).toBe(true);
});
});
29 changes: 29 additions & 0 deletions packages/app-shell/src/views/metadata-admin/clientValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,17 @@ export interface ValidateResult {
export async function validateMetadataDraft(
type: string,
draft: unknown,
/**
* The live server JSON schema for this type (from `/meta/types`, i.e.
* `RichMetadataTypeEntry.schema`). When provided it ROOT-CURES cross-repo
* spec skew: the bundled `@objectstack/spec` may lag the running server, so
* we never let the client be STRICTER than the server — a "missing required
* field" flagged by the (possibly stale) bundled Zod is suppressed when the
* server marks that field optional. The server's own validation on save
* stays authoritative. This makes the editor track the live schema without a
* per-change shim (cf. `FORWARD_COMPAT_FLOW_NODE_TYPES`).
*/
serverSchema?: { required?: unknown },
): Promise<ValidateResult> {
const schema = await getSchemaForType(type);
if (!schema) return { ok: true, issues: [] };
Expand All @@ -168,6 +179,24 @@ export async function validateMetadataDraft(
if (result.success) return { ok: true, issues: [] };

let rawIssues = result.error?.issues ?? [];

// Cross-repo skew root-cure — drop "missing required field" false positives
// for top-level fields the SERVER schema marks optional. Only suppresses when
// the field is actually absent in the draft (a present-but-invalid field
// still surfaces), so the client can never be stricter than the live server.
const serverRequired = Array.isArray(serverSchema?.required)
? new Set((serverSchema!.required as unknown[]).map((x) => String(x)))
: undefined;
if (serverRequired && draft && typeof draft === 'object' && !Array.isArray(draft)) {
const d = draft as Record<string, unknown>;
rawIssues = rawIssues.filter((i) => {
const path = i.path ?? [];
if (path.length !== 1) return true; // only top-level field issues
const field = String(path[0]);
const absent = d[field] === undefined || d[field] === null;
return !(absent && !serverRequired.has(field));
});
}
// Forward-compat: don't let the published flow schema's closed node-type
// enum reject node types the running server supports (see
// FORWARD_COMPAT_FLOW_NODE_TYPES). Suppress only the `.type` enum mismatch
Expand Down
Loading