Compare commits

...
Author SHA1 Message Date
martmull 4ca91e35ac Code review returns: Charles 2026-06-07 08:04:47 +02:00
martmull 17950fe938 Fix info 'shit' in dev mode 2026-06-06 08:11:04 +02:00
martmull d5c3fafe2a Fix spinner frames 2026-06-06 08:09:52 +02:00
martmull 62aff3b20f Merge branch 'main' into app-dev/remaining-improvements 2026-06-06 07:48:38 +02:00
martmull 4c81eef3bf Merge branch 'app-dev/dry-run-preview' into app-dev/remaining-improvements 2026-06-05 15:28:18 +02:00
martmull bab3aa8a01 Merge branch 'app-dev/serialize-dev-sync' into app-dev/dry-run-preview 2026-06-05 15:22:26 +02:00
martmull a4b2449ee6 Merge branch 'app-dev/surface-metadata-diff' into app-dev/serialize-dev-sync 2026-06-05 15:20:53 +02:00
martmull d3aae9ce90 Code review returns: Charles 2026-06-05 15:17:09 +02:00
martmull e1919f221c Fix generated 2026-06-05 14:52:52 +02:00
martmull 2e3ad1191c Fix lint 2026-06-05 14:51:36 +02:00
martmull cb14c3b7b2 Fix lint 2026-06-05 13:01:49 +02:00
martmull 326850944d feat(app-dev): surface metadata diff in dev sync and name failing migration actions
Render the applied metadata changes (created/updated/deleted with their
identifiers) in the dev sync output instead of only printing a generic
'Synced', and include the failing entity's universalIdentifier in
WorkspaceMigrationRunnerException messages so metadata conflicts are
diagnosable without reverse-engineering which object failed.
2026-06-05 13:00:43 +02:00
martmull f6ba2a7fc4 Fix lint 2026-06-05 12:59:50 +02:00
martmull c784a787ae Fix lint 2026-06-05 12:59:38 +02:00
martmull 8ad6bc4c74 Code review returns 2026-06-05 12:58:36 +02:00
martmull 86dd495bc1 feat(app-dev): sync error hints, flatEntity labels, dev-mode summary UI, and docs
Remaining app-dev improvements split out of #21240, on top of the dry-run PR:
- 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.
2026-06-05 12:54:16 +02:00
martmull 013294a5a8 feat(app-dev): show metadata changes on a normal dev --once sync
Render the applied created/updated/deleted summary after a successful
non-dry-run sync, matching the watch loop and the dry-run preview. Extract a
shared reportMetadataChanges helper so all three paths format the diff the
same way.
2026-06-05 12:42:29 +02:00
martmull d9e89daed0 feat(app-dev): add dry-run preview to dev sync
Add an opt-in dryRun flag to syncApplication that computes the metadata
migration plan and returns the actions without applying them. The validate/
build/run pipeline already separates build from run, so dryRun returns the
built actions before the runner executes, and skips all mutations (application
record update, schema migration, default role/tab sync, SDK generation,
registration metadata write) and the per-workspace lock.

CLI: 'yarn twenty dev --once --dry-run' builds the manifest, requests the diff,
and prints created/updated/deleted without writing anything.
2026-06-05 12:42:29 +02:00
martmull 97820bb2dd fix(app-dev): serialize dev sync per workspace with a cache lock
Concurrent syncApplication calls against the same workspace (e.g. multiple
agents iterating on an app) could interleave their metadata migrations and
read a stale workspace flat-entity cache, leaving metadata in a partially
applied state that required a full reset to recover.

Wrap the manifest sync in a per-workspace cache lock (app-sync:<workspaceId>)
so dev syncs run one at a time, mirroring the lock already used by the
application install path. The rate-limit throttle stays outside the lock.
2026-06-05 12:42:19 +02:00
martmull 473fd969a7 feat(app-dev): surface metadata diff in dev sync and name failing migration actions
Render the applied metadata changes (created/updated/deleted with their
identifiers) in the dev sync output instead of only printing a generic
'Synced', and include the failing entity's universalIdentifier in
WorkspaceMigrationRunnerException messages so metadata conflicts are
diagnosable without reverse-engineering which object failed.
2026-06-05 12:41:41 +02:00
27 changed files with 555 additions and 56 deletions
@@ -127,14 +127,16 @@ yarn twenty dev --once
|---------|----------|-------------|
| `yarn twenty dev` | Watches and re-syncs on every change. Runs until you stop it. | Interactive local development. |
| `yarn twenty dev --once` | Single build + sync, exits `0` on success, `1` on failure. | CI, pre-commit hooks, AI agents, scripted workflows. |
| `yarn twenty dev --once --dry-run` | Builds and prints the metadata changes **without applying them**. | Inspecting what a sync would change before committing to it. |
Both modes need an authenticated remote.
Both modes need an authenticated remote. See [Syncing & recovery](/developers/extend/apps/operations/sync-and-recovery#previewing-changes-dry-run) for more on `--dry-run`.
### Dev mode options
| Flag | Description |
|------|-------------|
| `--once` | Build and sync once, then exit. |
| `--dry-run` | With `--once`, preview the metadata changes without applying them. Writes nothing. |
| `--debounceMs <ms>` | Set the file-change debounce delay in milliseconds (default: `2000`). |
| `--verbose` / `--debug` | Show detailed build logs, sync requests, and error traces. |
@@ -20,6 +20,9 @@ The **operations layer** is everything you do *to* your app rather than *with* i
<Card title="CLI" icon="terminal" href="/developers/extend/apps/operations/cli">
`yarn twenty` reference — exec, logs, uninstall, remotes.
</Card>
<Card title="Syncing & recovery" icon="compass" href="/developers/extend/apps/operations/sync-and-recovery">
Which command when, reading the sync diff, and a recovery ladder.
</Card>
<Card title="Testing" icon="flask" href="/developers/extend/apps/operations/testing">
Vitest setup, integration tests, type checking, CI workflow.
</Card>
@@ -0,0 +1,111 @@
---
title: Syncing & recovery
description: Which command to use when, how to read the sync output, and a recovery ladder for when local metadata drifts — before reaching a full reset.
icon: "compass"
---
Local app development revolves around **syncing**: the CLI rebuilds your manifest and the server applies only the difference between it and the metadata already in your workspace. This page covers which command to reach for, how to read what a sync changed, and what to do — in order — when local state looks inconsistent.
## Which command, when
<Note>
For day-to-day local iteration you almost always want `yarn twenty dev`. Deploying and publishing are for shipping releases, **not** for the local loop.
</Note>
| You want to… | Command | Notes |
| --- | --- | --- |
| Iterate locally with live sync | `yarn twenty dev` | Watches your files and syncs on every change. |
| Sync once and exit (CI, scripts, hooks) | `yarn twenty dev --once` | One build + sync, then exits. |
| Preview changes **without applying them** | `yarn twenty dev --once --dry-run` | Computes and prints the diff; writes nothing. |
| Remove the app from the workspace | `yarn twenty app:uninstall` | Add `--yes` to skip the prompt. |
| Ship a tarball to a server | `yarn twenty app:publish --private` | Requires a **strictly higher** `package.json` version — see [Publishing](/developers/extend/apps/operations/publishing). |
| Publish to the marketplace (npm) | `yarn twenty app:publish` | — |
| Install / upgrade a deployed version | `yarn twenty app:install` | Installs the version currently deployed. |
| Wipe the local server and start clean | `yarn twenty docker:reset` | Deletes **all** local data — last resort. |
### Local sync does not need a version bump
The strictly-increasing `version` rule (`VERSION_ALREADY_EXISTS` on deploy, `APP_ALREADY_INSTALLED` / `CANNOT_DOWNGRADE_APPLICATION` on install) applies to **`app:publish` / `app:install`** — the release path. `yarn twenty dev` syncs your manifest in place and never requires a version change, so you don't need to touch `package.json` to iterate. If you find yourself bumping the version to test a local change, you're using the release path when you want the dev loop.
## Reading the sync output
Every sync prints the metadata changes it applied (or would apply, with `--dry-run`):
```text filename="Terminal"
Metadata changes: 2 created, 1 updated, 1 deleted
created objectMetadata rocket
created fieldMetadata timelineActivities
updated fieldMetadata launchedAt
deleted pageLayout legacyTab
✓ Synced
```
This is your first diagnostic: it tells you exactly which objects, fields, and layouts changed, so you can confirm a sync did what you expected before checking the UI.
When a sync fails on a single entity, the error names the offending entity and its `universalIdentifier`, for example:
```text
Migration action 'create' for 'fieldMetadata' (universalIdentifier: 2020...4337) failed
```
Use that identifier to find the entity in your manifest (and, if needed, in the workspace) instead of guessing which one conflicts.
## Previewing changes (dry run)
`yarn twenty dev --once --dry-run` builds your manifest, asks the server for the migration plan, and prints it — **without applying anything**. It's the safe way to answer "what would this sync change?" before committing to it.
```bash filename="Terminal"
yarn twenty dev --once --dry-run
```
```text filename="Terminal"
Building manifest...
Computing metadata diff (dry run, nothing will be applied)...
Metadata changes: 1 created, 1 updated
created fieldMetadata timelineActivities
updated objectMetadata rocket
✓ Dry run complete for My App — no changes were applied
```
A dry run:
- **Writes nothing** — no metadata migration, no application record update, no default role/tab changes, and no API client generation.
- Returns the **same diff** a real sync would apply, so you can review created/updated/deleted entities up front.
- Is useful before a risky change, when reviewing an AI-generated change, or in a script that should fail if an unexpected change is about to land.
<Note>
A dry run only previews **metadata** changes, and it requires the app to have been synced at least once (so the workspace knows about it). If you run it against an app that was never synced, the server reports that the app is not installed — run `yarn twenty dev` once first.
</Note>
## Recovery ladder
When local metadata looks wrong, escalate in this order and stop as soon as you're unblocked. Each step is more disruptive than the last.
1. **Re-sync.** Run `yarn twenty dev --once` again. Syncs are idempotent — re-running a clean manifest is safe and often resolves a transient hiccup.
2. **Preview the plan.** Run `yarn twenty dev --once --dry-run` to see exactly what the next sync intends to change, without applying it.
3. **Read the named error.** If a sync fails, note the metadata type and `universalIdentifier` in the message (see above) and locate that entity in your manifest. A conflict usually points to a duplicated or re-used identifier.
4. **Uninstall and reinstall.** `yarn twenty app:uninstall`, then sync again (`yarn twenty dev`). This rebuilds the app's metadata from a clean slate while keeping the rest of your workspace intact.
5. **Full reset (last resort).** `yarn twenty docker:reset`, then re-seed and re-sync.
<Warning>
`yarn twenty docker:reset` deletes **all** data in your local instance — every workspace, record, and app. Only use it once the earlier steps have failed.
</Warning>
<Note>
Hit a metadata error? Please [open an issue](https://github.com/twentyhq/twenty/issues/new/choose) and include the failing migration message (with its metadata type and `universalIdentifier`), the `Metadata changes` output from the sync, and the commands you ran.
</Note>
## Avoid concurrent syncs on one workspace
Syncing applies metadata migrations. Running several sync, deploy, or install operations against the **same workspace at the same time** — for example, multiple terminals or AI agents iterating in parallel — can interleave those migrations and leave metadata in a partially-applied state.
The server serializes syncs per workspace to prevent this, but you should still funnel sensitive metadata operations through a **single** process rather than firing them concurrently. If you orchestrate development with multiple agents, route their sync/deploy/install calls through one queue so only one runs at a time.
## Telling failures apart
When something goes wrong, the metadata diff and named errors let you place the failure:
- **Manifest build error** — the CLI fails before syncing (`MANIFEST_BUILD_FAILED`, `TYPECHECK_FAILED`); fix your app source.
- **Sync / migration error** — the build succeeds but applying the diff fails, naming the entity and `universalIdentifier`; fix the conflicting metadata.
- **App code runtime error** — the sync succeeds but your logic functions or components misbehave at runtime; check [function logs](/developers/extend/apps/operations/cli).
- **Local instance state** — none of the above and the workspace still looks wrong; work down the recovery ladder.
+14
View File
@@ -429,6 +429,7 @@
"pages": [
"developers/extend/apps/operations/overview",
"developers/extend/apps/operations/cli",
"developers/extend/apps/operations/sync-and-recovery",
"developers/extend/apps/operations/testing",
"developers/extend/apps/operations/publishing"
]
@@ -861,6 +862,7 @@
"pages": [
"developers/extend/apps/operations/overview",
"developers/extend/apps/operations/cli",
"developers/extend/apps/operations/sync-and-recovery",
"developers/extend/apps/operations/testing",
"developers/extend/apps/operations/publishing"
]
@@ -1293,6 +1295,7 @@
"pages": [
"developers/extend/apps/operations/overview",
"developers/extend/apps/operations/cli",
"developers/extend/apps/operations/sync-and-recovery",
"developers/extend/apps/operations/testing",
"developers/extend/apps/operations/publishing"
]
@@ -1725,6 +1728,7 @@
"pages": [
"developers/extend/apps/operations/overview",
"developers/extend/apps/operations/cli",
"developers/extend/apps/operations/sync-and-recovery",
"developers/extend/apps/operations/testing",
"developers/extend/apps/operations/publishing"
]
@@ -2157,6 +2161,7 @@
"pages": [
"l/de/developers/extend/apps/operations/overview",
"l/de/developers/extend/apps/operations/cli",
"developers/extend/apps/operations/sync-and-recovery",
"l/de/developers/extend/apps/operations/testing",
"l/de/developers/extend/apps/operations/publishing"
]
@@ -2589,6 +2594,7 @@
"pages": [
"developers/extend/apps/operations/overview",
"developers/extend/apps/operations/cli",
"developers/extend/apps/operations/sync-and-recovery",
"developers/extend/apps/operations/testing",
"developers/extend/apps/operations/publishing"
]
@@ -3021,6 +3027,7 @@
"pages": [
"developers/extend/apps/operations/overview",
"developers/extend/apps/operations/cli",
"developers/extend/apps/operations/sync-and-recovery",
"developers/extend/apps/operations/testing",
"developers/extend/apps/operations/publishing"
]
@@ -3453,6 +3460,7 @@
"pages": [
"developers/extend/apps/operations/overview",
"developers/extend/apps/operations/cli",
"developers/extend/apps/operations/sync-and-recovery",
"developers/extend/apps/operations/testing",
"developers/extend/apps/operations/publishing"
]
@@ -3885,6 +3893,7 @@
"pages": [
"developers/extend/apps/operations/overview",
"developers/extend/apps/operations/cli",
"developers/extend/apps/operations/sync-and-recovery",
"developers/extend/apps/operations/testing",
"developers/extend/apps/operations/publishing"
]
@@ -4317,6 +4326,7 @@
"pages": [
"l/pt/developers/extend/apps/operations/overview",
"l/pt/developers/extend/apps/operations/cli",
"developers/extend/apps/operations/sync-and-recovery",
"l/pt/developers/extend/apps/operations/testing",
"l/pt/developers/extend/apps/operations/publishing"
]
@@ -4749,6 +4759,7 @@
"pages": [
"l/ro/developers/extend/apps/operations/overview",
"l/ro/developers/extend/apps/operations/cli",
"developers/extend/apps/operations/sync-and-recovery",
"l/ro/developers/extend/apps/operations/testing",
"l/ro/developers/extend/apps/operations/publishing"
]
@@ -5181,6 +5192,7 @@
"pages": [
"l/ru/developers/extend/apps/operations/overview",
"l/ru/developers/extend/apps/operations/cli",
"developers/extend/apps/operations/sync-and-recovery",
"l/ru/developers/extend/apps/operations/testing",
"l/ru/developers/extend/apps/operations/publishing"
]
@@ -5613,6 +5625,7 @@
"pages": [
"l/tr/developers/extend/apps/operations/overview",
"l/tr/developers/extend/apps/operations/cli",
"developers/extend/apps/operations/sync-and-recovery",
"l/tr/developers/extend/apps/operations/testing",
"l/tr/developers/extend/apps/operations/publishing"
]
@@ -6045,6 +6058,7 @@
"pages": [
"l/zh/developers/extend/apps/operations/overview",
"l/zh/developers/extend/apps/operations/cli",
"developers/extend/apps/operations/sync-and-recovery",
"l/zh/developers/extend/apps/operations/testing",
"l/zh/developers/extend/apps/operations/publishing"
]
@@ -431,6 +431,7 @@
"pages": [
"developers/extend/apps/operations/overview",
"developers/extend/apps/operations/cli",
"developers/extend/apps/operations/sync-and-recovery",
"developers/extend/apps/operations/testing",
"developers/extend/apps/operations/publishing"
]
@@ -45,7 +45,7 @@ export class AppDevCommand {
orchestratorState.onChange = () => uiStateManager.notify();
const { unmount } = await renderDevUI(uiStateManager);
const { unmount } = await renderDevUI(uiStateManager, options.verbose);
this.unmountUI = unmount;
@@ -1,4 +1,5 @@
import { formatPath } from '@/cli/utilities/file/file-path';
import chalk from 'chalk';
import type { Command } from 'commander';
import { SyncableEntity } from 'twenty-shared/application';
import { EntityAddCommand } from './add';
@@ -25,6 +26,14 @@ export const registerDevCommands = (program: Command): void => {
dryRun?: boolean;
},
) => {
if (options.dryRun && !options.once) {
console.warn(
chalk.yellow(
'--dry-run only applies with --once. Ignoring it; run `yarn twenty dev --once --dry-run` to preview changes.',
),
);
}
const commonOptions = {
appPath: formatPath(appPath),
verbose: options.verbose || options.debug,
@@ -56,7 +65,7 @@ export const registerDevCommands = (program: Command): void => {
'--dry-run',
'Preview the metadata changes without applying them (requires --once)',
)
.option('--debounceMs <ms>', 'Debounce in ms (default: 2 000)')
.option('--debounceMs <ms>', 'Debounce in ms (default: 1 000)')
.option('-v, --verbose', 'Show detailed logs')
.option('-d, --debug', 'Show detailed logs (alias for --verbose)')
.action(devAction);
@@ -16,10 +16,12 @@ import { ClientService } from '@/cli/utilities/client/client-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { formatSyncActionsSummary } from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary';
import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors';
import { getSyncErrorRecoveryHint } from '@/cli/utilities/error/get-sync-error-recovery-hint';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import { FileUploader } from '@/cli/utilities/file/file-uploader';
import { runSafe } from '@/cli/utilities/run-safe';
import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
import chalk from 'chalk';
export type AppDevOnceOptions = {
appPath: string;
@@ -44,6 +46,12 @@ const reportMetadataChanges = (
}
};
const appendRecoveryHint = (message: string, error: unknown): string => {
const hint = getSyncErrorRecoveryHint(error);
return hint ? `${message}\n\n${hint}` : message;
};
const innerAppDevOnce = async (
options: AppDevOnceOptions,
): Promise<CommandResult<AppDevOnceResult>> => {
@@ -95,7 +103,7 @@ const innerAppDevOnce = async (
}
for (const warning of manifestResult.warnings) {
onProgress?.(`${warning}`);
onProgress?.(chalk.yellow(`${warning}`));
}
onProgress?.('Building application files...');
@@ -154,7 +162,7 @@ const innerAppDevOnce = async (
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message,
message: appendRecoveryHint(message, dryRunResult.error),
},
};
}
@@ -260,7 +268,7 @@ const innerAppDevOnce = async (
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message,
message: appendRecoveryHint(message, syncResult.error),
},
};
}
@@ -149,9 +149,6 @@ describe('manifestValidate', () => {
expect(result.errors).toContain(
'Duplicate universal identifiers: 550e8400-e29b-41d4-a716-446655440001',
);
expect(result.warnings).toContain('No object defined');
expect(result.warnings).toContain('No logic function defined');
expect(result.warnings).toContain('No front component defined');
});
it('should fail when extension field ID conflicts with object field ID', () => {
@@ -192,9 +189,6 @@ describe('manifestValidate', () => {
expect(result.errors).toContain(
'Duplicate universal identifiers: 550e8400-e29b-41d4-a716-446655440001',
);
expect(result.warnings).not.toContain('No object defined');
expect(result.warnings).toContain('No logic function defined');
expect(result.warnings).toContain('No front component defined');
});
});
@@ -366,6 +360,54 @@ describe('manifestValidate', () => {
});
});
describe('agent responseFormat validation', () => {
it('should warn for each agent without a responseFormat', () => {
const result = manifestValidate({
...validManifest,
agents: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440040',
name: 'agentWithoutFormat',
label: 'Agent Without Format',
prompt: 'Do something',
},
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440041',
name: 'anotherAgentWithoutFormat',
label: 'Another Agent Without Format',
prompt: 'Do something else',
},
],
});
expect(result.warnings).toContain(
'Agent "agentWithoutFormat" has no responseFormat defined',
);
expect(result.warnings).toContain(
'Agent "anotherAgentWithoutFormat" has no responseFormat defined',
);
});
it('should not warn for an agent that has a responseFormat', () => {
const result = manifestValidate({
...validManifest,
agents: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440042',
name: 'agentWithFormat',
label: 'Agent With Format',
prompt: 'Do something',
responseFormat: { type: 'text' },
},
],
});
expect(result.warnings).not.toContain(
'Agent "agentWithFormat" has no responseFormat defined',
);
});
});
describe('UUID version validation', () => {
it('should pass with UUID v4 identifiers', () => {
const result = manifestValidate({
@@ -2,7 +2,7 @@ import { validate as uuidValidate, version as uuidVersion } from 'uuid';
import { type FieldManifest, type Manifest } from 'twenty-shared/application';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { isNonEmptyArray } from 'twenty-shared/utils';
import { isDefined } from 'twenty-shared/utils';
const MIN_UUID_VERSION = 4;
@@ -150,20 +150,14 @@ export const manifestValidate = (manifest: Manifest) => {
if (invalidUniversalIdentifiers.length > 0) {
errors.push(
`Duplicate universal identifiers: ${invalidUniversalIdentifiers.join(', ')}`,
`Invalid universal identifiers: ${invalidUniversalIdentifiers.join(', ')}`,
);
}
if (!isNonEmptyArray(manifest.objects)) {
warnings.push('No object defined');
}
if (!isNonEmptyArray(manifest.logicFunctions)) {
warnings.push('No logic function defined');
}
if (!isNonEmptyArray(manifest.frontComponents)) {
warnings.push('No front component defined');
for (const agent of manifest.agents) {
if (!isDefined(agent.responseFormat)) {
warnings.push(`Agent "${agent.name}" has no responseFormat defined`);
}
}
const allFields: Pick<
@@ -42,7 +42,7 @@ export class DevModeOrchestrator {
private startWatchersStep: StartWatchersOrchestratorStep;
constructor(options: DevModeOrchestratorOptions) {
this.debounceMs = options.debounceMs ?? 2_000;
this.debounceMs = options.debounceMs ?? 1_000;
this.state = options.state;
this.verbose = options.verbose ?? false;
@@ -9,6 +9,7 @@ import {
} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { formatSyncActionsSummary } from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary';
import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors';
import { getSyncErrorRecoveryHint } from '@/cli/utilities/error/get-sync-error-recovery-hint';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import { type Manifest } from 'twenty-shared/application';
@@ -100,6 +101,12 @@ export class SyncApplicationOrchestratorStep {
});
}
const recoveryHint = getSyncErrorRecoveryHint(syncResult.error);
if (recoveryHint) {
events.push({ message: recoveryHint, status: 'info' });
}
const summaryMessage = errorEvents ? errorEvents[0].message : 'Sync failed';
step.output = { syncStatus: 'error', error: summaryMessage };
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { type OrchestratorStateEntityInfo } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { summarizeEntityStatuses } from '@/cli/utilities/dev/ui/dev-ui-constants';
const entity = (
name: string,
status: OrchestratorStateEntityInfo['status'],
): OrchestratorStateEntityInfo => ({ name, path: name, status });
describe('summarizeEntityStatuses', () => {
it('counts every status in display order', () => {
const parts = summarizeEntityStatuses([
entity('a', 'success'),
entity('b', 'success'),
entity('c', 'error'),
entity('d', 'building'),
]);
expect(parts).toEqual([
{ status: 'success', count: 2, label: 'synced' },
{ status: 'building', count: 1, label: 'building' },
{ status: 'error', count: 1, label: 'error' },
]);
});
it('returns only the non-zero statuses', () => {
const parts = summarizeEntityStatuses([
entity('a', 'success'),
entity('b', 'success'),
]);
expect(parts).toEqual([{ status: 'success', count: 2, label: 'synced' }]);
});
});
@@ -15,6 +15,7 @@ import { useStatusIcon } from '@/cli/utilities/dev/ui/dev-ui-hooks';
import { useInk } from '@/cli/utilities/dev/ui/dev-ui-ink-context';
import {
DevUiEntitySection,
DevUiEntitySummary,
ENTITY_ORDER,
} from '@/cli/utilities/dev/ui/components/dev-ui-entity-section';
import { DevUiVersionRow } from '@/cli/utilities/dev/ui/components/dev-ui-version-row';
@@ -62,8 +63,10 @@ export const DevUiStepStatusLabel = ({
export const DevUiApplicationPanel = ({
state,
verbose = false,
}: {
state: OrchestratorState;
verbose?: boolean;
}): React.ReactElement => {
const { Box, Text } = useInk();
const groupedEntities = groupEntitiesByType(state.entities);
@@ -111,13 +114,17 @@ export const DevUiApplicationPanel = ({
</Box>
<Box marginLeft={2} flexDirection="column">
{ENTITY_ORDER.map((type) => {
const entities = groupedEntities.get(type) ?? [];
{verbose ? (
ENTITY_ORDER.map((type) => {
const entities = groupedEntities.get(type) ?? [];
return (
<DevUiEntitySection key={type} type={type} entities={entities} />
);
})}
return (
<DevUiEntitySection key={type} type={type} entities={entities} />
);
})
) : (
<DevUiEntitySummary entities={Array.from(state.entities.values())} />
)}
</Box>
</Box>
);
@@ -8,6 +8,7 @@ import {
UPLOAD_FRAMES,
mapFileStatusToDevUiStatus,
shortenPath,
summarizeEntityStatuses,
} from '@/cli/utilities/dev/ui/dev-ui-constants';
import { useStatusIcon } from '@/cli/utilities/dev/ui/dev-ui-hooks';
import { useInk } from '@/cli/utilities/dev/ui/dev-ui-ink-context';
@@ -67,6 +68,35 @@ export const DevUiEntitySection = ({
);
};
export const DevUiEntitySummary = ({
entities,
}: {
entities: OrchestratorStateEntityInfo[];
}): React.ReactElement | null => {
const { Box, Text } = useInk();
if (entities.length === 0) return null;
const parts = summarizeEntityStatuses(entities);
return (
<Box marginTop={1}>
<Text bold dimColor>
Entities{' '}
</Text>
{parts.map((part, index) => (
<Box key={part.status}>
{index > 0 && <Text dimColor> · </Text>}
<DevUiStatusIcon uiStatus={mapFileStatusToDevUiStatus(part.status)} />
<Text>
{part.count} {part.label}
</Text>
</Box>
))}
</Box>
);
};
export const DevUiEntityLegend = (): React.ReactElement => {
const { Box, Text } = useInk();
@@ -18,8 +18,10 @@ const SETTLE_DELAY_MS = 80;
const DevUI = ({
uiStateManager,
verbose,
}: {
uiStateManager: DevUiStateManager;
verbose: boolean;
}): React.ReactElement => {
const { Box, Static } = useInk();
@@ -85,8 +87,8 @@ const DevUI = ({
</Static>
<Box marginTop={1} flexDirection="column">
<DevUiApplicationPanel state={state} />
<DevUiEntityLegend />
<DevUiApplicationPanel state={state} verbose={verbose} />
{verbose && <DevUiEntityLegend />}
</Box>
</>
);
@@ -94,15 +96,15 @@ const DevUI = ({
export const renderDevUI = async (
uiStateManager: DevUiStateManager,
verbose = false,
): Promise<{ unmount: () => void }> => {
const ink = await import('ink');
const { render, Box, Text, Static } = ink;
const { unmount } = render(
<InkProvider value={{ Box, Text, Static }}>
<DevUI uiStateManager={uiStateManager} />
<DevUI uiStateManager={uiStateManager} verbose={verbose} />
</InkProvider>,
{ incrementalRendering: true },
);
return { unmount };
@@ -78,20 +78,9 @@ export const SYNC_STATUS_LABELS: Record<OrchestratorStateSyncStatus, string> = {
error: 'Error',
};
export const SPINNER_FRAMES = [
'⠋',
'⠙',
'⠹',
'⠸',
'⠼',
'⠴',
'⠦',
'⠧',
'⠇',
'⠏',
];
export const SPINNER_FRAMES = ['◐', '◓', '◑', '◒'];
export const UPLOAD_FRAMES = ['↑', '⇡', '', ''];
export const UPLOAD_FRAMES = ['↑', '⇡', '', ''];
export const ENTITY_LABELS: Record<SyncableEntity, string> = {
[SyncableEntity.Object]: 'Objects',
@@ -158,6 +147,43 @@ export const groupEntitiesByType = (
return grouped;
};
export type DevUiEntityStatusSummaryPart = {
status: OrchestratorStateFileStatus;
count: number;
label: string;
};
const ENTITY_STATUS_SUMMARY_ORDER: {
status: OrchestratorStateFileStatus;
label: string;
}[] = [
{ status: 'success', label: 'synced' },
{ status: 'building', label: 'building' },
{ status: 'uploading', label: 'uploading' },
{ status: 'pending', label: 'pending' },
{ status: 'error', label: 'error' },
];
export const summarizeEntityStatuses = (
entities: OrchestratorStateEntityInfo[],
): DevUiEntityStatusSummaryPart[] => {
const counts: Record<OrchestratorStateFileStatus, number> = {
pending: 0,
building: 0,
uploading: 0,
success: 0,
error: 0,
};
for (const entity of entities) {
counts[entity.status] += 1;
}
return ENTITY_STATUS_SUMMARY_ORDER.filter(
({ status }) => counts[status] > 0,
).map(({ status, label }) => ({ status, count: counts[status], label }));
};
export const getApplicationUrl = (state: OrchestratorState): string | null => {
const applicationId = state.steps.resolveApplication.output.applicationId;
@@ -0,0 +1,47 @@
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 (by code)', () => {
const hint = getSyncErrorRecoveryHint({
message:
'Application "x" is not installed in workspace "y". Install it first.',
extensions: { code: 'APP_NOT_INSTALLED' },
});
expect(hint).toContain('yarn twenty dev --once');
expect(hint).toContain('register');
});
it('suggests an initial sync when the app is not installed (by message string)', () => {
const hint = getSyncErrorRecoveryHint(
'Application "x" is not installed in workspace "y". Install it first.',
);
expect(hint).toContain('yarn twenty dev --once');
});
it('suggests previewing and reinstalling on a metadata conflict', () => {
const hint = getSyncErrorRecoveryHint({
message:
"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 string', () => {
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();
});
});
@@ -0,0 +1,53 @@
import { isObject, isString } from '@sniptt/guards';
const getErrorMessage = (error: unknown): string => {
if (isString(error)) {
return error;
}
if (isObject(error)) {
const message = (error as { message?: unknown }).message;
if (isString(message)) {
return message;
}
}
return '';
};
const getErrorCode = (error: unknown): string => {
if (isObject(error)) {
const extensions = (error as { extensions?: { code?: unknown } })
.extensions;
if (isObject(extensions) && isString(extensions.code)) {
return extensions.code;
}
}
return '';
};
// Maps a known sync failure to a one-line next action the developer can take,
// so the CLI points to a recovery step instead of leaving them to guess.
export const getSyncErrorRecoveryHint = (
error: unknown,
): string | undefined => {
const message = getErrorMessage(error).toLowerCase();
const code = getErrorCode(error);
if (code === 'APP_NOT_INSTALLED' || message.includes('not installed')) {
return 'Hint: run `yarn twenty dev --once` to register the app in this workspace, then retry.';
}
if (
message.includes('already exists') ||
message.includes('universalidentifier') ||
/migration action .* failed/.test(message)
) {
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;
};
@@ -52,10 +52,7 @@ export class ApplicationSyncService {
hasSchemaMetadataChanged: boolean;
}> {
const ownerFlatApplication: FlatApplication = dryRun
? await this.applicationService.findOneApplicationOrThrow({
universalIdentifier: manifest.application.universalIdentifier,
workspaceId,
})
? await this.findInstalledApplicationOrThrow({ workspaceId, manifest })
: await this.syncApplication({
workspaceId,
manifest,
@@ -77,6 +74,30 @@ export class ApplicationSyncService {
return syncResult;
}
private async findInstalledApplicationOrThrow({
workspaceId,
manifest,
}: {
workspaceId: string;
manifest: Manifest;
}): Promise<ApplicationEntity> {
const application = await this.applicationService.findByUniversalIdentifier(
{
universalIdentifier: manifest.application.universalIdentifier,
workspaceId,
},
);
if (!application) {
throw new ApplicationException(
`Application "${manifest.application.universalIdentifier}" is not installed in workspace "${workspaceId}". Install it first.`,
ApplicationExceptionCode.APP_NOT_INSTALLED,
);
}
return application;
}
// Registers the application + only the pre-install logic function in
// workspace metadata so the pre-install hook can resolve and execute it
// before the main synchronizeFromManifest runs the full migrations.
@@ -40,7 +40,7 @@ export const fromLogicFunctionManifestToUniversalFlatLogicFunction = ({
workflowActionTriggerSettings:
logicFunctionManifest.workflowActionTriggerSettings ?? null,
isBuildUpToDate: true,
executionMode: LogicFunctionExecutionMode.PREBUILT,
executionMode: LogicFunctionExecutionMode.LIVE,
createdAt: now,
updatedAt: now,
deletedAt: null,
@@ -0,0 +1,11 @@
import { type AllMetadataName } from 'twenty-shared/metadata';
import { type MetadataUniversalFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-universal-flat-entity.type';
import { type MetadataUniversalFlatEntityPropertiesToCompare } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-properties-to-compare.type';
export type UniversalFlatEntityDiff<T extends AllMetadataName> = {
[K in MetadataUniversalFlatEntityPropertiesToCompare<T>]?: {
before: MetadataUniversalFlatEntity<T>[K];
after: MetadataUniversalFlatEntity<T>[K];
};
};
@@ -18,6 +18,7 @@ import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-e
import { WorkspaceMigrationBuilderAdditionalCacheDataMaps } from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-builder-additional-cache-data-maps.type';
import { AllUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/all-universal-flat-entity-maps.type';
import { MetadataUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-maps.type';
import { UniversalFlatEntityDiff } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-diff.type';
import { UniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-maps.type';
import { addUniversalFlatEntityToUniversalFlatEntityAndRelatedEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/add-universal-flat-entity-to-universal-flat-entity-and-related-entity-maps-through-mutation-or-throw.util';
import { deleteUniversalFlatEntityForeignKeyAggregators } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/delete-universal-flat-entity-foreign-key-aggregators.util';
@@ -164,7 +165,11 @@ export abstract class WorkspaceEntityMigrationBuilderService<
actionsResult.delete.push(
...(Array.isArray(validationResult.action)
? validationResult.action
: [validationResult.action]),
: [validationResult.action]
).map((action) => ({
...action,
flatEntity: universalFlatEntityToDelete,
})),
);
}
@@ -221,6 +226,17 @@ export abstract class WorkspaceEntityMigrationBuilderService<
...flatEntityUpdate.update,
};
const diff = Object.fromEntries(
Object.entries(flatEntityUpdate.update).map(([key, after]) => [
key,
{
before:
existingFlatEntity[key as keyof MetadataUniversalFlatEntity<T>],
after,
},
]),
) as UniversalFlatEntityDiff<T>;
replaceUniversalFlatEntityInUniversalFlatEntityMapsThroughMutationOrThrow(
{
universalFlatEntity: updatedFlatEntity,
@@ -232,7 +248,12 @@ export abstract class WorkspaceEntityMigrationBuilderService<
actionsResult.update.push(
...(Array.isArray(validationResult.action)
? validationResult.action
: [validationResult.action]),
: [validationResult.action]
).map((action) => ({
...action,
flatEntity: updatedFlatEntity,
diff,
})),
);
}
@@ -1,5 +1,6 @@
import { type AllMetadataName } from 'twenty-shared/metadata';
import { type MetadataUniversalFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-universal-flat-entity.type';
import { type WORKSPACE_MIGRATION_ACTION_TYPE } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/constants/workspace-migration-action-type.constant';
export type BaseUniversalDeleteWorkspaceMigrationAction<
@@ -8,4 +9,5 @@ export type BaseUniversalDeleteWorkspaceMigrationAction<
universalIdentifier: string;
type: typeof WORKSPACE_MIGRATION_ACTION_TYPE.delete;
metadataName: T;
flatEntity?: MetadataUniversalFlatEntity<T>;
};
@@ -1,5 +1,7 @@
import { type AllMetadataName } from 'twenty-shared/metadata';
import { type MetadataUniversalFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-universal-flat-entity.type';
import { type UniversalFlatEntityDiff } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-diff.type';
import { type UniversalFlatEntityUpdate } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-update.type';
import { type WORKSPACE_MIGRATION_ACTION_TYPE } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/constants/workspace-migration-action-type.constant';
@@ -10,4 +12,6 @@ export type BaseUniversalUpdateWorkspaceMigrationAction<
metadataName: T;
universalIdentifier: string;
update: UniversalFlatEntityUpdate<T>;
flatEntity?: MetadataUniversalFlatEntity<T>;
diff?: UniversalFlatEntityDiff<T>;
};
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`syncApplication should create a TEXT field on the standard Company object 1`] = `
{
@@ -382,6 +382,53 @@ exports[`syncApplication should delete old field and create equivalent one when
"syncApplication": {
"actions": [
{
"flatEntity": {
"applicationId": Any<String>,
"applicationUniversalIdentifier": Any<String>,
"calendarViewIds": [],
"calendarViewUniversalIdentifiers": [],
"createdAt": Any<String>,
"defaultValue": null,
"description": "Ticket description",
"fieldPermissionIds": [],
"fieldPermissionUniversalIdentifiers": [],
"icon": "IconFileDescription",
"id": Any<String>,
"isActive": true,
"isCustom": true,
"isLabelSyncedWithName": false,
"isNullable": true,
"isSystem": false,
"isUIReadOnly": false,
"isUnique": false,
"kanbanAggregateOperationViewIds": [],
"kanbanAggregateOperationViewUniversalIdentifiers": [],
"label": "Description",
"mainGroupByFieldMetadataViewIds": [],
"mainGroupByFieldMetadataViewUniversalIdentifiers": [],
"morphId": null,
"name": "description",
"objectMetadataId": Any<String>,
"objectMetadataUniversalIdentifier": Any<String>,
"options": null,
"relationTargetFieldMetadataId": null,
"relationTargetFieldMetadataUniversalIdentifier": null,
"relationTargetObjectMetadataId": null,
"relationTargetObjectMetadataUniversalIdentifier": null,
"settings": null,
"standardOverrides": null,
"type": "TEXT",
"universalIdentifier": Any<String>,
"universalSettings": null,
"updatedAt": Any<String>,
"viewFieldIds": [],
"viewFieldUniversalIdentifiers": [],
"viewFilterIds": [],
"viewFilterUniversalIdentifiers": [],
"viewSortIds": [],
"viewSortUniversalIdentifiers": [],
"workspaceId": Any<String>,
},
"metadataName": "fieldMetadata",
"type": "delete",
"universalIdentifier": Any<String>,
@@ -69,6 +69,8 @@ export const DOCUMENTATION_PATHS = {
'/developers/extend/apps/operations/overview',
DEVELOPERS_EXTEND_APPS_OPERATIONS_PUBLISHING:
'/developers/extend/apps/operations/publishing',
DEVELOPERS_EXTEND_APPS_OPERATIONS_SYNC_AND_RECOVERY:
'/developers/extend/apps/operations/sync-and-recovery',
DEVELOPERS_EXTEND_APPS_OPERATIONS_TESTING:
'/developers/extend/apps/operations/testing',
DEVELOPERS_EXTEND_OAUTH: '/developers/extend/oauth',