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:
+136
-150
@@ -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');
|
||||
|
||||
+36
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user