feat(app-dev): sync error hints, flatEntity labels, dev-mode summary UI, and docs (#21252)

Split out of #21240 — all remaining app-dev improvements. Stacked on
#21251 (review/merge that first).

- Actionable recovery hints on failed syncs; unified diff renderer;
`--dry-run` guard.
- Return `flatEntity` on update/delete sync actions and unify the diff
label.
- Summarize the dev-mode entity list unless `--verbose`.
- Docs: syncing & recovery guide + dry-run + open-an-issue prompt.
- Live execution mode for synced logic functions; clearer manifest
warnings.

<img width="1018" height="700" alt="image"
src="https://github.com/user-attachments/assets/5e9ce19e-0f1d-4f99-8524-4e118bde932b"
/>
This commit is contained in:
martmull
2026-06-08 17:43:28 +02:00
committed by GitHub
parent 13e8e26d1c
commit 77d1e8ced6
34 changed files with 716 additions and 292 deletions
@@ -1,59 +1,42 @@
import { type MetadataValidationErrorResponse } from 'twenty-shared/metadata';
import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors';
describe('formatManifestValidationErrors', () => {
it('should return null for null input', () => {
expect(formatManifestValidationErrors(null)).toBeNull();
});
it('should return null for undefined input', () => {
expect(formatManifestValidationErrors(undefined)).toBeNull();
});
it('should return null for non-object input', () => {
expect(formatManifestValidationErrors('string error')).toBeNull();
expect(formatManifestValidationErrors(42)).toBeNull();
});
it('should return null when extensions is missing', () => {
expect(formatManifestValidationErrors({ message: 'error' })).toBeNull();
});
it('should return null when extensions.errors is missing', () => {
it('should return null when errors or summary is missing', () => {
expect(
formatManifestValidationErrors({
extensions: { summary: { totalErrors: 1 } },
}),
formatManifestValidationErrors({} as MetadataValidationErrorResponse),
).toBeNull();
});
it('should return null when extensions.summary is missing', () => {
expect(
formatManifestValidationErrors({
extensions: { errors: {} },
}),
summary: { totalErrors: 1 },
} as MetadataValidationErrorResponse),
).toBeNull();
});
it('should format a single error', () => {
const events = formatManifestValidationErrors({
extensions: {
errors: {
fieldMetadata: [
{
flatEntityMinimalInformation: {
universalIdentifier: 'field-uuid-1',
},
errors: [
{
code: 'INVALID_NAME',
message: 'Field name is invalid',
},
],
errors: {
fieldMetadata: [
{
type: 'fieldMetadata',
flatEntityMinimalInformation: {
universalIdentifier: 'field-uuid-1',
},
],
},
summary: { fieldMetadata: 1, totalErrors: 1 },
errors: [
{
code: 'INVALID_NAME',
message: 'Field name is invalid',
},
],
},
],
},
summary: { fieldMetadata: 1, totalErrors: 1 },
});
expect(events).not.toBeNull();
@@ -73,24 +56,26 @@ describe('formatManifestValidationErrors', () => {
it('should format multiple errors across metadata types', () => {
const events = formatManifestValidationErrors({
extensions: {
errors: {
fieldMetadata: [
{
errors: [
{ code: 'ERR_1', message: 'First error' },
{ code: 'ERR_2', message: 'Second error' },
],
},
],
objectMetadata: [
{
errors: [{ code: 'ERR_3', message: 'Third error' }],
},
],
},
summary: { fieldMetadata: 2, objectMetadata: 1, totalErrors: 3 },
errors: {
fieldMetadata: [
{
type: 'fieldMetadata',
flatEntityMinimalInformation: {},
errors: [
{ code: 'ERR_1', message: 'First error' },
{ code: 'ERR_2', message: 'Second error' },
],
},
],
objectMetadata: [
{
type: 'objectMetadata',
flatEntityMinimalInformation: {},
errors: [{ code: 'ERR_3', message: 'Third error' }],
},
],
},
summary: { fieldMetadata: 2, objectMetadata: 1, totalErrors: 3 },
});
expect(events).not.toBeNull();
@@ -104,50 +89,51 @@ describe('formatManifestValidationErrors', () => {
it('should format errors with details for both objectMetadata and fieldMetadata', () => {
const events = formatManifestValidationErrors({
extensions: {
errors: {
objectMetadata: [
{
flatEntityMinimalInformation: {
universalIdentifier: 'obj-uuid-1',
},
errors: [
{
code: 'DUPLICATE_NAME',
message: 'An object with this name already exists',
value: 'postCard',
},
],
errors: {
objectMetadata: [
{
type: 'objectMetadata',
flatEntityMinimalInformation: {
universalIdentifier: 'obj-uuid-1',
},
],
fieldMetadata: [
{
flatEntityMinimalInformation: {
universalIdentifier: 'field-uuid-1',
errors: [
{
code: 'DUPLICATE_NAME',
message: 'An object with this name already exists',
value: 'postCard',
},
errors: [
{
code: 'INVALID_TYPE',
message: 'Field type is not supported',
value: 'UNKNOWN_TYPE',
},
],
],
},
],
fieldMetadata: [
{
type: 'fieldMetadata',
flatEntityMinimalInformation: {
universalIdentifier: 'field-uuid-1',
},
{
flatEntityMinimalInformation: {
universalIdentifier: 'field-uuid-2',
errors: [
{
code: 'INVALID_TYPE',
message: 'Field type is not supported',
value: 'UNKNOWN_TYPE',
},
errors: [
{
code: 'MISSING_RELATION_TARGET',
message: 'Relation target object not found',
},
],
],
},
{
type: 'fieldMetadata',
flatEntityMinimalInformation: {
universalIdentifier: 'field-uuid-2',
},
],
},
summary: { objectMetadata: 1, fieldMetadata: 2, totalErrors: 3 },
errors: [
{
code: 'MISSING_RELATION_TARGET',
message: 'Relation target object not found',
},
],
},
],
},
summary: { objectMetadata: 1, fieldMetadata: 2, totalErrors: 3 },
});
expect(events).not.toBeNull();
@@ -171,22 +157,22 @@ describe('formatManifestValidationErrors', () => {
it('should include value in details when present', () => {
const events = formatManifestValidationErrors({
extensions: {
errors: {
fieldMetadata: [
{
errors: [
{
code: 'INVALID_VALUE',
message: 'Bad value',
value: 'some-bad-value',
},
],
},
],
},
summary: { fieldMetadata: 1, totalErrors: 1 },
errors: {
fieldMetadata: [
{
type: 'fieldMetadata',
flatEntityMinimalInformation: {},
errors: [
{
code: 'INVALID_VALUE',
message: 'Bad value',
value: 'some-bad-value',
},
],
},
],
},
summary: { fieldMetadata: 1, totalErrors: 1 },
});
expect(events).not.toBeNull();
@@ -195,16 +181,16 @@ describe('formatManifestValidationErrors', () => {
it('should omit details suffix when no value or universalIdentifier', () => {
const events = formatManifestValidationErrors({
extensions: {
errors: {
fieldMetadata: [
{
errors: [{ code: 'ERR', message: 'Something failed' }],
},
],
},
summary: { fieldMetadata: 1, totalErrors: 1 },
errors: {
fieldMetadata: [
{
type: 'fieldMetadata',
flatEntityMinimalInformation: {},
errors: [{ code: 'ERR', message: 'Something failed' }],
},
],
},
summary: { fieldMetadata: 1, totalErrors: 1 },
});
expect(events).not.toBeNull();
@@ -213,19 +199,19 @@ describe('formatManifestValidationErrors', () => {
it('should fall back to entries.length when summary count is missing for a metadata type', () => {
const events = formatManifestValidationErrors({
extensions: {
errors: {
fieldMetadata: [
{
errors: [
{ code: 'ERR_1', message: 'Error one' },
{ code: 'ERR_2', message: 'Error two' },
],
},
],
},
summary: { totalErrors: 2 },
errors: {
fieldMetadata: [
{
type: 'fieldMetadata',
flatEntityMinimalInformation: {},
errors: [
{ code: 'ERR_1', message: 'Error one' },
{ code: 'ERR_2', message: 'Error two' },
],
},
],
},
summary: { totalErrors: 2 },
});
expect(events).not.toBeNull();
@@ -234,35 +220,35 @@ describe('formatManifestValidationErrors', () => {
it('should pluralize correctly for singular and plural counts', () => {
const singleError = formatManifestValidationErrors({
extensions: {
errors: {
objectMetadata: [
{
errors: [{ code: 'ERR', message: 'Error' }],
},
],
},
summary: { objectMetadata: 1, totalErrors: 1 },
errors: {
objectMetadata: [
{
type: 'objectMetadata',
flatEntityMinimalInformation: {},
errors: [{ code: 'ERR', message: 'Error' }],
},
],
},
summary: { objectMetadata: 1, totalErrors: 1 },
});
expect(singleError?.[0].message).toBe('Sync failed with 1 error');
expect(singleError?.[1].message).toBe('objectMetadata: 1 error');
const multipleErrors = formatManifestValidationErrors({
extensions: {
errors: {
objectMetadata: [
{
errors: [
{ code: 'ERR_1', message: 'Error 1' },
{ code: 'ERR_2', message: 'Error 2' },
],
},
],
},
summary: { objectMetadata: 5, totalErrors: 5 },
errors: {
objectMetadata: [
{
type: 'objectMetadata',
flatEntityMinimalInformation: {},
errors: [
{ code: 'ERR_1', message: 'Error 1' },
{ code: 'ERR_2', message: 'Error 2' },
],
},
],
},
summary: { objectMetadata: 5, totalErrors: 5 },
});
expect(multipleErrors?.[0].message).toBe('Sync failed with 5 errors');
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { getSyncErrorRecoveryHint } from '@/cli/utilities/error/get-sync-error-recovery-hint';
describe('getSyncErrorRecoveryHint', () => {
it('suggests an initial sync when the app is not installed', () => {
const hint = getSyncErrorRecoveryHint(
'Application "x" is not installed in workspace "y". Install it first.',
);
expect(hint).toContain('yarn twenty dev --once');
expect(hint).toContain('register');
});
it('suggests previewing and reinstalling on a metadata conflict', () => {
const hint = getSyncErrorRecoveryHint(
"Migration action 'create' for 'fieldMetadata' (universalIdentifier: 2020) failed",
);
expect(hint).toContain('yarn twenty dev --once --dry-run');
expect(hint).toContain('yarn twenty app:uninstall -y');
});
it('suggests previewing on an already-exists error', () => {
const hint = getSyncErrorRecoveryHint(
'Field with same universal identifier already exists in object',
);
expect(hint).toContain('yarn twenty dev --once --dry-run');
});
it('returns undefined for an unrecognized error', () => {
expect(getSyncErrorRecoveryHint('Network request failed')).toBeUndefined();
expect(getSyncErrorRecoveryHint(undefined)).toBeUndefined();
});
});
@@ -1,44 +1,34 @@
import { isNonEmptyString } from '@sniptt/guards';
import {
type AllMetadataName,
type MetadataValidationErrorResponse,
} from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { type OrchestratorStateStepEvent } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
type SyncValidationEntry = {
flatEntityMinimalInformation?: { universalIdentifier?: string };
errors: { code: string; message: string; value?: string }[];
};
type StructuredSyncError = {
message?: string;
extensions?: {
code?: string;
errors?: Record<string, SyncValidationEntry[]>;
summary?: Record<string, number> & { totalErrors: number };
message?: string;
};
};
export const formatManifestValidationErrors = (
error: unknown,
error: MetadataValidationErrorResponse | undefined,
): OrchestratorStateStepEvent[] | null => {
if (!error || typeof error !== 'object') {
return null;
}
const syncError = error as StructuredSyncError;
const extensions = syncError.extensions;
if (!extensions?.errors || !extensions?.summary) {
if (!isDefined(error?.errors) || !isDefined(error?.summary)) {
return null;
}
const events: OrchestratorStateStepEvent[] = [];
const totalErrors = extensions.summary.totalErrors;
const totalErrors = error.summary.totalErrors;
events.push({
message: `Sync failed with ${totalErrors} error${totalErrors !== 1 ? 's' : ''}`,
status: 'error',
});
for (const [metadataName, entries] of Object.entries(extensions.errors)) {
const count = extensions.summary[metadataName] ?? entries.length;
for (const [metadataName, entries] of Object.entries(error.errors)) {
if (!isDefined(entries)) {
continue;
}
const count =
error.summary[metadataName as AllMetadataName] ?? entries.length;
events.push({
message: `${metadataName}: ${count} error${count !== 1 ? 's' : ''}`,
@@ -54,11 +44,11 @@ export const formatManifestValidationErrors = (
for (const entryError of entry.errors) {
const details: string[] = [];
if (entryError.value) {
details.push(`value: ${entryError.value}`);
if (isDefined(entryError.value)) {
details.push(`value: ${String(entryError.value)}`);
}
if (universalIdentifier) {
if (isNonEmptyString(universalIdentifier)) {
details.push(`universalIdentifier: ${universalIdentifier}`);
}
@@ -0,0 +1,19 @@
export const getSyncErrorRecoveryHint = (
message: string | undefined,
): string | undefined => {
const normalizedMessage = (message ?? '').toLowerCase();
if (normalizedMessage.includes('not installed')) {
return 'Hint: run `yarn twenty dev --once` to register the app in this workspace, then retry.';
}
if (
normalizedMessage.includes('already exists') ||
normalizedMessage.includes('universalidentifier') ||
/migration action .* failed/.test(normalizedMessage)
) {
return 'Hint: a metadata conflict was detected. Preview the plan with `yarn twenty dev --once --dry-run`; if it persists, run `yarn twenty app:uninstall -y` then sync again.';
}
return undefined;
};