Compare commits

...
Author SHA1 Message Date
martmull 21cc8308ce Merge branch 'main' into 2467-app-feedback-from-an-app-developer 2026-06-05 16:05:39 +02:00
martmull 67960eca14 Fix lint 2026-06-05 15:31:36 +02:00
martmull c530baff3c Reduce debounce default 2026-06-05 15:31:01 +02:00
martmull 8187935acf feat(app-dev): summarize the dev-mode entity list unless --verbose
Without --verbose, collapse the per-file entity sections to a single status
line that reflects every state via its icon and count, e.g.
'Entities  ✓ 6 synced · ⠋ 2 building · ✗ 1 error'. With --verbose, keep the
full per-type file listing and the icon legend.
2026-06-05 11:55:45 +02:00
martmull cc8ffe1dbc feat(app-dev): return flatEntity on update/delete sync actions and unify the diff label
The sync response only carried flatEntity on create actions, so the CLI could
only show a name for created entities and fell back to the raw universalIdentifier
for updates and deletes. Enrich update/delete actions in the sync response with
their entity (resolved from the manifest for updates, from the existing slice for
deletes), and resolve the CLI label from flatEntity uniformly for all operations.
2026-06-05 11:17:57 +02:00
martmull 9408c89394 docs(apps): prompt to open an issue on unresolved metadata errors
Add a note to the recovery ladder asking developers to open a GitHub issue
(with the named migration error, universalIdentifier, and metadata-changes
output) when a metadata error isn't resolved by the recovery steps.
2026-06-05 10:02:29 +02:00
martmull 0412ae9c97 Merge branch '2467-app-feedback-from-an-app-developer' of https://github.com/twentyhq/twenty into 2467-app-feedback-from-an-app-developer 2026-06-05 09:49:02 +02:00
martmull c54d22e06e feat(app-dev): actionable sync error hints, unified diff renderer, dry-run guard
- Append a recovery hint to failed syncs (and dry runs) that maps known
  failures to a next action: APP_NOT_INSTALLED suggests an initial sync,
  metadata conflicts suggest --dry-run then uninstall/reinstall. Applied in
  both the watch loop and dev --once.
- Unify the metadata-diff rendering behind formatSyncActionsSummaryFromData so
  the watch loop and dev --once can't drift apart.
- Warn when --dry-run is passed without --once instead of silently ignoring it.
2026-06-05 09:48:15 +02:00
github-actionsandmartmull ea9259dc51 chore: sync docs artifacts 2026-06-05 09:48:15 +02:00
martmull 3ebffeac6e Merge branch '2467-app-feedback-from-an-app-developer' of https://github.com/twentyhq/twenty into 2467-app-feedback-from-an-app-developer 2026-06-05 07:48:32 +02:00
github-actions 7c33cde7f4 chore: sync docs artifacts 2026-06-05 05:36:04 +00:00
martmull ec2c2250d3 docs(apps): document the dev --once --dry-run option
Add a dedicated 'Previewing changes (dry run)' section to the syncing &
recovery guide (what it does, example output, when to use, requirements) and
list --dry-run in the quick-start dev command flags and modes tables with a
cross-link.
2026-06-05 07:35:24 +02:00
martmull 5a0eb9d922 docs(apps): add syncing & recovery guide
Document which command to use when (dev vs dry-run vs uninstall vs deploy/
publish vs reset), how to read the metadata-changes diff and named migration
errors, a step-by-step recovery ladder before a full reset, and guidance to
avoid concurrent syncs on one workspace. Registers the page in the apps
Operations nav and overview.
2026-06-05 07:29:19 +02:00
martmull a69b83ad4e Fix generated 2026-06-05 07:26:54 +02:00
martmull 2a5db95f60 Fix lint 2026-06-05 07:17:56 +02:00
martmull fa3b38913e 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 07:16:08 +02:00
martmull ba1dca1bed Update warnigns 2026-06-05 07:14:40 +02:00
martmull bbd95c8fe9 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 06:57:29 +02:00
martmull fc83da63b7 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-04 23:19:07 +02:00
martmull 420b2afca9 Update warnigns 2026-06-04 23:18:00 +02:00
martmull 42eff9dc93 Use live execution mode for syncApplication 2026-06-04 23:03:37 +02:00
martmull 7fb65a1ae2 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-04 22:32:40 +02:00
46 changed files with 1491 additions and 69 deletions
@@ -3360,7 +3360,7 @@ type Mutation {
syncMarketplaceCatalog: Boolean!
createDevelopmentApplication(universalIdentifier: String!, name: String!): DevelopmentApplication!
generateApplicationToken(applicationId: UUID!): ApplicationTokenPair!
syncApplication(manifest: JSON!): WorkspaceMigration!
syncApplication(manifest: JSON!, dryRun: Boolean): WorkspaceMigration!
uploadApplicationFile(file: Upload!, applicationUniversalIdentifier: String!, fileFolder: FileFolder!, filePath: String!): File!
upgradeApplication(appRegistrationId: String!, targetVersion: String!): Boolean!
renewApplicationToken(applicationRefreshToken: String!): ApplicationTokenPair!
@@ -5972,7 +5972,7 @@ export interface MutationGenqlSelection{
syncMarketplaceCatalog?: boolean | number
createDevelopmentApplication?: (DevelopmentApplicationGenqlSelection & { __args: {universalIdentifier: Scalars['String'], name: Scalars['String']} })
generateApplicationToken?: (ApplicationTokenPairGenqlSelection & { __args: {applicationId: Scalars['UUID']} })
syncApplication?: (WorkspaceMigrationGenqlSelection & { __args: {manifest: Scalars['JSON']} })
syncApplication?: (WorkspaceMigrationGenqlSelection & { __args: {manifest: Scalars['JSON'], dryRun?: (Scalars['Boolean'] | null)} })
uploadApplicationFile?: (FileGenqlSelection & { __args: {file: Scalars['Upload'], applicationUniversalIdentifier: Scalars['String'], fileFolder: FileFolder, filePath: Scalars['String']} })
upgradeApplication?: { __args: {appRegistrationId: Scalars['String'], targetVersion: Scalars['String']} }
renewApplicationToken?: (ApplicationTokenPairGenqlSelection & { __args: {applicationRefreshToken: Scalars['String']} })
@@ -8761,6 +8761,9 @@ export default {
"manifest": [
15,
"JSON!"
],
"dryRun": [
6
]
}
],
@@ -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"
]
@@ -3315,6 +3315,7 @@ export type MutationStopAgentChatStreamArgs = {
export type MutationSyncApplicationArgs = {
dryRun?: InputMaybe<Scalars['Boolean']>;
manifest: Scalars['JSON'];
};
@@ -7,6 +7,7 @@ import chalk from 'chalk';
export type AppDevOnceCommandOptions = {
appPath?: string;
verbose?: boolean;
dryRun?: boolean;
};
export class AppDevOnceCommand {
@@ -17,12 +18,17 @@ export class AppDevOnceCommand {
const remoteName = ConfigService.getActiveRemote();
console.log(chalk.blue(`Syncing application on ${remoteName}...`));
console.log(
chalk.blue(
`${options.dryRun ? 'Previewing application diff' : 'Syncing application'} on ${remoteName}...`,
),
);
console.log(chalk.gray(`App path: ${appPath}\n`));
const result = await appDevOnce({
appPath,
verbose: options.verbose,
dryRun: options.dryRun,
onProgress: (message) => console.log(chalk.gray(message)),
});
@@ -31,6 +37,16 @@ export class AppDevOnceCommand {
process.exit(1);
}
if (options.dryRun) {
console.log(
chalk.green(
`\n✓ Dry run complete for ${result.data.applicationDisplayName} — no changes were applied`,
),
);
return;
}
console.log(
chalk.green(
`\n✓ Synced ${result.data.applicationDisplayName} (${result.data.fileCount} file${result.data.fileCount === 1 ? '' : 's'})`,
@@ -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';
@@ -22,8 +23,17 @@ export const registerDevCommands = (program: Command): void => {
verbose?: boolean;
debug?: boolean;
debounceMs?: string;
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,
@@ -33,7 +43,10 @@ export const registerDevCommands = (program: Command): void => {
};
if (options.once) {
await devOnceCommand.execute(commonOptions);
await devOnceCommand.execute({
...commonOptions,
dryRun: options.dryRun,
});
return;
}
@@ -48,7 +61,11 @@ export const registerDevCommands = (program: Command): void => {
'-o, --once',
'Build and sync once, then exit (useful for CI, scripts, and pre-commit hooks)',
)
.option('--debounceMs <ms>', 'Debounce in ms (default: 2 000)')
.option(
'--dry-run',
'Preview the metadata changes without applying them (requires --once)',
)
.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);
@@ -1,5 +1,5 @@
import path from 'path';
import { OUTPUT_DIR, type Manifest } from 'twenty-shared/application';
import { type Manifest, OUTPUT_DIR } from 'twenty-shared/application';
import { ApiService } from '@/cli/utilities/api/api-service';
import {
@@ -13,15 +13,19 @@ import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest
import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer';
import { ClientService } from '@/cli/utilities/client/client-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { formatSyncActionsSummaryFromData } 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;
verbose?: boolean;
dryRun?: boolean;
onProgress?: (message: string) => void;
};
@@ -32,10 +36,25 @@ export type AppDevOnceResult = {
applicationUniversalIdentifier: string;
};
const reportMetadataChanges = (
data: unknown,
onProgress?: (message: string) => void,
): void => {
for (const event of formatSyncActionsSummaryFromData(data)) {
onProgress?.(event.message);
}
};
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>> => {
const { appPath, onProgress, verbose } = options;
const { appPath, onProgress, verbose, dryRun } = options;
onProgress?.('Checking server...');
@@ -83,7 +102,7 @@ const innerAppDevOnce = async (
}
for (const warning of manifestResult.warnings) {
onProgress?.(`${warning}`);
onProgress?.(chalk.yellow(`${warning}`));
}
onProgress?.('Building application files...');
@@ -120,6 +139,47 @@ const innerAppDevOnce = async (
await writeManifestToOutput(appPath, manifest);
if (dryRun) {
onProgress?.(
'Computing metadata diff (dry run, nothing will be applied)...',
);
const dryRunResult = await apiService.syncApplication(manifest, {
dryRun: true,
});
if (!dryRunResult.success) {
const errorEvents = verbose
? null
: formatManifestValidationErrors(dryRunResult.error);
const message = errorEvents
? errorEvents.map((event) => event.message).join('\n')
: `Dry run failed with error: ${serializeError(dryRunResult.error)}`;
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message: appendRecoveryHint(message, dryRunResult.error),
},
};
}
reportMetadataChanges(dryRunResult.data, onProgress);
return {
success: true,
data: {
outputDir: path.join(appPath, OUTPUT_DIR),
fileCount: buildResult.builtFileInfos.size,
applicationDisplayName: manifest.application.displayName,
applicationUniversalIdentifier:
manifest.application.universalIdentifier,
},
};
}
onProgress?.('Registering application...');
const configService = new ConfigService();
@@ -207,11 +267,13 @@ const innerAppDevOnce = async (
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message,
message: appendRecoveryHint(message, syncResult.error),
},
};
}
reportMetadataChanges(syncResult.data, onProgress);
onProgress?.('Generating API client...');
try {
@@ -72,8 +72,11 @@ export class ApiService {
return this.applicationApi.createDevelopmentApplication(...args);
}
syncApplication(manifest: Manifest): Promise<ApiResponse> {
return this.applicationApi.syncApplication(manifest);
syncApplication(
manifest: Manifest,
options?: { dryRun?: boolean },
): Promise<ApiResponse> {
return this.applicationApi.syncApplication(manifest, options);
}
uninstallApplication(universalIdentifier: string): Promise<ApiResponse> {
@@ -251,18 +251,21 @@ export class ApplicationApi {
}
}
async syncApplication(manifest: Manifest): Promise<ApiResponse> {
async syncApplication(
manifest: Manifest,
options?: { dryRun?: boolean },
): Promise<ApiResponse> {
try {
const mutation = `
mutation SyncApplication($manifest: JSON!) {
syncApplication(manifest: $manifest) {
mutation SyncApplication($manifest: JSON!, $dryRun: Boolean) {
syncApplication(manifest: $manifest, dryRun: $dryRun) {
applicationUniversalIdentifier
actions
}
}
`;
const variables = { manifest };
const variables = { manifest, dryRun: options?.dryRun ?? false };
const response: AxiosResponse = await this.client.post(
'/metadata',
@@ -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;
@@ -0,0 +1,164 @@
import { describe, expect, it } from 'vitest';
import {
formatSyncActionsSummary,
formatSyncActionsSummaryFromData,
} from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary';
describe('formatSyncActionsSummary', () => {
it('reports no changes when the actions list is empty', () => {
expect(formatSyncActionsSummary([])).toEqual([
{ message: 'No metadata changes', status: 'info' },
]);
});
it('summarizes created, updated and deleted actions with their identifiers', () => {
const events = formatSyncActionsSummary([
{
type: 'create',
metadataName: 'objectMetadata',
flatEntity: {
universalIdentifier: 'uid-object',
nameSingular: 'rocket',
},
},
{
type: 'create',
metadataName: 'fieldMetadata',
flatEntity: {
universalIdentifier: 'uid-field',
name: 'timelineActivities',
},
},
{
type: 'update',
metadataName: 'fieldMetadata',
universalIdentifier: 'uid-updated-field',
},
{
type: 'delete',
metadataName: 'pageLayout',
universalIdentifier: 'uid-page-layout',
},
]);
expect(events).toEqual([
{
message: 'Metadata changes: 2 created, 1 updated, 1 deleted',
status: 'info',
},
{ message: ' created objectMetadata rocket', status: 'info' },
{ message: ' created fieldMetadata timelineActivities', status: 'info' },
{ message: ' updated fieldMetadata uid-updated-field', status: 'info' },
{ message: ' deleted pageLayout uid-page-layout', status: 'info' },
]);
});
it('uses the entity name for update and delete actions that carry a flatEntity', () => {
const events = formatSyncActionsSummary([
{
type: 'update',
metadataName: 'objectMetadata',
universalIdentifier: 'uid-object',
flatEntity: {
universalIdentifier: 'uid-object',
nameSingular: 'rocket',
},
},
{
type: 'delete',
metadataName: 'fieldMetadata',
universalIdentifier: 'uid-field',
flatEntity: { universalIdentifier: 'uid-field', name: 'legacyField' },
},
]);
expect(events).toEqual([
{ message: 'Metadata changes: 1 updated, 1 deleted', status: 'info' },
{ message: ' updated objectMetadata rocket', status: 'info' },
{ message: ' deleted fieldMetadata legacyField', status: 'info' },
]);
});
it('falls back to the universal identifier when a created entity has no name', () => {
const events = formatSyncActionsSummary([
{
type: 'create',
metadataName: 'fieldMetadata',
flatEntity: { universalIdentifier: 'uid-nameless' },
},
]);
expect(events).toEqual([
{ message: 'Metadata changes: 1 created', status: 'info' },
{ message: ' created fieldMetadata uid-nameless', status: 'info' },
]);
});
it('truncates the detail lines when there are more changes than the display limit', () => {
const actions = Array.from({ length: 55 }, (_, index) => ({
type: 'create',
metadataName: 'fieldMetadata',
flatEntity: {
universalIdentifier: `uid-${index}`,
name: `field${index}`,
},
}));
const events = formatSyncActionsSummary(actions);
expect(events[0]).toEqual({
message: 'Metadata changes: 55 created',
status: 'info',
});
expect(events).toHaveLength(52);
expect(events[51]).toEqual({
message: ' …and 5 more change(s)',
status: 'info',
});
});
it('ignores entries that are not recognizable migration actions', () => {
const events = formatSyncActionsSummary([
null,
'not-an-action',
{ type: 'unknown', metadataName: 'fieldMetadata' },
]);
expect(events).toEqual([
{ message: 'No metadata changes', status: 'info' },
]);
});
});
describe('formatSyncActionsSummaryFromData', () => {
it('extracts the actions from a sync response and formats them', () => {
expect(
formatSyncActionsSummaryFromData({
applicationUniversalIdentifier: 'app-uid',
actions: [
{
type: 'create',
metadataName: 'fieldMetadata',
flatEntity: {
universalIdentifier: 'uid',
name: 'timelineActivities',
},
},
],
}),
).toEqual([
{ message: 'Metadata changes: 1 created', status: 'info' },
{ message: ' created fieldMetadata timelineActivities', status: 'info' },
]);
});
it('reports no changes when the data has no actions array', () => {
expect(formatSyncActionsSummaryFromData(undefined)).toEqual([
{ message: 'No metadata changes', status: 'info' },
]);
expect(formatSyncActionsSummaryFromData({ actions: 'nope' })).toEqual([
{ message: 'No metadata changes', status: 'info' },
]);
});
});
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest';
import { type ApiService } from '@/cli/utilities/api/api-service';
import { OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { SyncApplicationOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step';
import { type Manifest } from 'twenty-shared/application';
vi.mock('@/cli/utilities/build/manifest/manifest-update-checksums', () => ({
manifestUpdateChecksums: ({ manifest }: { manifest: unknown }) => manifest,
}));
vi.mock('@/cli/utilities/build/manifest/manifest-writer', () => ({
writeManifestToOutput: vi.fn(),
}));
const buildStep = (
syncApplication: ApiService['syncApplication'],
): { state: OrchestratorState; step: SyncApplicationOrchestratorStep } => {
const state = new OrchestratorState({ appPath: '/tmp/app' });
const apiService = { syncApplication } as unknown as ApiService;
const step = new SyncApplicationOrchestratorStep({
apiService,
state,
notify: () => {},
});
return { state, step };
};
const executeInput = {
manifest: {
application: { displayName: 'Demo' },
} as unknown as Manifest,
builtFileInfos: new Map(),
appPath: '/tmp/app',
};
describe('SyncApplicationOrchestratorStep', () => {
it('renders the applied metadata changes on a successful sync', async () => {
const syncApplication = vi.fn().mockResolvedValue({
success: true,
data: {
applicationUniversalIdentifier: 'app-uid',
actions: [
{
type: 'create',
metadataName: 'fieldMetadata',
flatEntity: {
universalIdentifier: 'uid-field',
name: 'timelineActivities',
},
},
],
},
});
const { state, step } = buildStep(syncApplication);
await step.execute(executeInput);
const messages = state.events.map((event) => event.message);
expect(messages).toContain('Metadata changes: 1 created');
expect(messages).toContain(' created fieldMetadata timelineActivities');
expect(messages).toContain('✓ Synced');
});
it('reports no metadata changes when the sync applies nothing', async () => {
const syncApplication = vi.fn().mockResolvedValue({
success: true,
data: { applicationUniversalIdentifier: 'app-uid', actions: [] },
});
const { state, step } = buildStep(syncApplication);
await step.execute(executeInput);
const messages = state.events.map((event) => event.message);
expect(messages).toContain('No metadata changes');
expect(messages).toContain('✓ Synced');
});
});
@@ -0,0 +1,113 @@
import { type OrchestratorStateStepEvent } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
const MAX_DETAIL_LINES = 50;
const VERB_BY_TYPE = {
create: 'created',
update: 'updated',
delete: 'deleted',
} as const;
type ParsedSyncAction = {
type: keyof typeof VERB_BY_TYPE;
metadataName: string;
label: string;
};
const getStringProperty = (
value: Record<string, unknown>,
key: string,
): string | undefined => {
const property = value[key];
return typeof property === 'string' ? property : undefined;
};
const parseSyncAction = (action: unknown): ParsedSyncAction | null => {
if (typeof action !== 'object' || action === null) {
return null;
}
const record = action as Record<string, unknown>;
const type = record.type;
const metadataName = getStringProperty(record, 'metadataName');
if (
(type !== 'create' && type !== 'update' && type !== 'delete') ||
metadataName === undefined
) {
return null;
}
const flatEntity =
typeof record.flatEntity === 'object' && record.flatEntity !== null
? (record.flatEntity as Record<string, unknown>)
: {};
const label =
getStringProperty(flatEntity, 'name') ??
getStringProperty(flatEntity, 'nameSingular') ??
getStringProperty(flatEntity, 'universalIdentifier') ??
getStringProperty(record, 'universalIdentifier') ??
'unknown';
return { type, metadataName, label };
};
export const formatSyncActionsSummaryFromData = (
data: unknown,
): OrchestratorStateStepEvent[] => {
const actions = Array.isArray((data as { actions?: unknown[] })?.actions)
? (data as { actions: unknown[] }).actions
: [];
return formatSyncActionsSummary(actions);
};
export const formatSyncActionsSummary = (
actions: unknown[],
): OrchestratorStateStepEvent[] => {
const parsedActions = actions
.map(parseSyncAction)
.filter((action): action is ParsedSyncAction => action !== null);
if (parsedActions.length === 0) {
return [{ message: 'No metadata changes', status: 'info' }];
}
const counts = { create: 0, update: 0, delete: 0 };
for (const action of parsedActions) {
counts[action.type] += 1;
}
const summaryParts = [
counts.create > 0 ? `${counts.create} created` : null,
counts.update > 0 ? `${counts.update} updated` : null,
counts.delete > 0 ? `${counts.delete} deleted` : null,
].filter((part): part is string => part !== null);
const events: OrchestratorStateStepEvent[] = [
{ message: `Metadata changes: ${summaryParts.join(', ')}`, status: 'info' },
];
const visibleActions = parsedActions.slice(0, MAX_DETAIL_LINES);
for (const action of visibleActions) {
events.push({
message: ` ${VERB_BY_TYPE[action.type]} ${action.metadataName} ${action.label}`,
status: 'info',
});
}
const hiddenCount = parsedActions.length - visibleActions.length;
if (hiddenCount > 0) {
events.push({
message: ` …and ${hiddenCount} more change(s)`,
status: 'info',
});
}
return events;
};
@@ -7,7 +7,9 @@ import {
type OrchestratorStateStepEvent,
type OrchestratorStateSyncStatus,
} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { formatSyncActionsSummaryFromData } 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';
@@ -69,6 +71,7 @@ export class SyncApplicationOrchestratorStep {
const syncResult = await this.apiService.syncApplication(manifest);
if (syncResult.success) {
events.push(...formatSyncActionsSummaryFromData(syncResult.data));
events.push({ message: '✓ Synced', status: 'success' });
step.output = { syncStatus: 'synced', error: null };
step.status = 'done';
@@ -96,6 +99,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,13 +96,14 @@ 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 },
);
@@ -158,6 +158,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,55 @@
const getErrorMessage = (error: unknown): string => {
if (typeof error === 'string') {
return error;
}
if (typeof error === 'object' && error !== null) {
const message = (error as { message?: unknown }).message;
if (typeof message === 'string') {
return message;
}
}
return '';
};
const getErrorCode = (error: unknown): string => {
if (typeof error === 'object' && error !== null) {
const extensions = (error as { extensions?: { code?: unknown } })
.extensions;
if (
typeof extensions === 'object' &&
extensions !== null &&
typeof extensions.code === 'string'
) {
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;
};
@@ -0,0 +1,139 @@
jest.mock('graphql-upload/GraphQLUpload.mjs', () => ({
__esModule: true,
default: class GraphQLUpload {},
}));
import { ApplicationDevelopmentResolver } from 'src/engine/core-modules/application/application-development/application-development.resolver';
import { ApplicationInput } from 'src/engine/core-modules/application/application-development/dtos/application.input';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
const WORKSPACE_ID = 'workspace-1';
const UNIVERSAL_IDENTIFIER = 'app-uid-1';
const buildManifest = (): ApplicationInput['manifest'] =>
({
application: {
universalIdentifier: UNIVERSAL_IDENTIFIER,
displayName: 'Demo App',
},
}) as unknown as ApplicationInput['manifest'];
describe('ApplicationDevelopmentResolver', () => {
let resolver: ApplicationDevelopmentResolver;
let withLock: jest.Mock;
let synchronizeFromManifest: jest.Mock;
beforeEach(() => {
withLock = jest.fn((fn: () => Promise<unknown>) => fn());
synchronizeFromManifest = jest.fn().mockResolvedValue({
workspaceMigration: {
applicationUniversalIdentifier: UNIVERSAL_IDENTIFIER,
actions: [],
},
hasSchemaMetadataChanged: false,
});
const cacheLockService = {
withLock,
} as unknown as CacheLockService;
const applicationSyncService = {
synchronizeFromManifest,
} as unknown as ApplicationSyncService;
const applicationService = {
findByUniversalIdentifier: jest.fn().mockResolvedValue({
id: 'application-1',
version: '1.0.0',
universalIdentifier: UNIVERSAL_IDENTIFIER,
}),
} as unknown as ApplicationService;
const applicationRegistrationService = {
findOneByUniversalIdentifier: jest
.fn()
.mockResolvedValue({ id: 'registration-1' }),
updateFromManifest: jest.fn(),
} as unknown as ApplicationRegistrationService;
const sdkClientGenerationService = {
generateSdkClientForApplication: jest.fn(),
} as unknown as SdkClientGenerationService;
const twentyConfigService = {
get: jest.fn().mockReturnValue('http://localhost:3000'),
} as unknown as TwentyConfigService;
const throttlerService = {
tokenBucketThrottleOrThrow: jest.fn(),
} as unknown as ThrottlerService;
resolver = new ApplicationDevelopmentResolver(
{} as unknown as ApplicationTokenService,
applicationService,
applicationSyncService,
applicationRegistrationService,
{} as unknown as ApplicationRegistrationVariableService,
{} as unknown as FileStorageService,
sdkClientGenerationService,
twentyConfigService,
throttlerService,
cacheLockService,
);
});
it('runs the manifest sync inside a per-workspace cache lock', async () => {
await resolver.syncApplication({ manifest: buildManifest() }, {
id: WORKSPACE_ID,
} as WorkspaceEntity);
expect(withLock).toHaveBeenCalledWith(
expect.any(Function),
`app-sync:${WORKSPACE_ID}`,
expect.any(Object),
);
expect(synchronizeFromManifest).toHaveBeenCalledTimes(1);
});
it('does not run the manifest sync when the lock body is not executed', async () => {
withLock.mockResolvedValue({
applicationUniversalIdentifier: UNIVERSAL_IDENTIFIER,
actions: [],
});
await resolver.syncApplication({ manifest: buildManifest() }, {
id: WORKSPACE_ID,
} as WorkspaceEntity);
expect(synchronizeFromManifest).not.toHaveBeenCalled();
});
it('computes the migration plan without acquiring the lock on a dry run', async () => {
const result = await resolver.syncApplication(
{ manifest: buildManifest(), dryRun: true },
{ id: WORKSPACE_ID } as WorkspaceEntity,
);
expect(withLock).not.toHaveBeenCalled();
expect(synchronizeFromManifest).toHaveBeenCalledWith({
workspaceId: WORKSPACE_ID,
manifest: expect.any(Object),
dryRun: true,
});
expect(result).toEqual({
applicationUniversalIdentifier: UNIVERSAL_IDENTIFIER,
actions: [],
});
});
});
@@ -5,6 +5,7 @@ import { ApplicationManifestModule } from 'src/engine/core-modules/application/a
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationDevelopmentResolver } from 'src/engine/core-modules/application/application-development/application-development.resolver';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
@@ -17,6 +18,7 @@ import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/wor
ApplicationModule,
ApplicationManifestModule,
ApplicationRegistrationModule,
CacheLockModule,
FeatureFlagModule,
SdkClientModule,
TokenModule,
@@ -33,6 +33,7 @@ import {
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util';
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
@@ -52,6 +53,8 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer';
const APP_DEV_RATE_LIMIT_MAX = 30;
const APP_DEV_RATE_LIMIT_WINDOW_MS = 30_000;
const APP_SYNC_LOCK_OPTIONS = { ttl: 60_000, ms: 500, maxRetries: 120 };
@UsePipes(ResolverValidationPipe)
@MetadataResolver()
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
@@ -71,6 +74,7 @@ export class ApplicationDevelopmentResolver {
private readonly sdkClientGenerationService: SdkClientGenerationService,
private readonly twentyConfigService: TwentyConfigService,
private readonly throttlerService: ThrottlerService,
private readonly cacheLockService: CacheLockService,
) {}
@Mutation(() => DevelopmentApplicationDTO)
@@ -129,7 +133,7 @@ export class ApplicationDevelopmentResolver {
@Mutation(() => WorkspaceMigrationDTO)
async syncApplication(
@Args() { manifest }: ApplicationInput,
@Args() { manifest, dryRun }: ApplicationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<WorkspaceMigrationDTO> {
await this.throttlePerApplication(
@@ -137,6 +141,32 @@ export class ApplicationDevelopmentResolver {
workspaceId,
);
if (dryRun === true) {
const { workspaceMigration } =
await this.applicationSyncService.synchronizeFromManifest({
workspaceId,
manifest,
dryRun: true,
});
return {
applicationUniversalIdentifier:
workspaceMigration.applicationUniversalIdentifier,
actions: workspaceMigration.actions,
};
}
return this.cacheLockService.withLock(
() => this.applyManifestSync(manifest, workspaceId),
`app-sync:${workspaceId}`,
APP_SYNC_LOCK_OPTIONS,
);
}
private async applyManifestSync(
manifest: ApplicationInput['manifest'],
workspaceId: string,
): Promise<WorkspaceMigrationDTO> {
const applicationRegistrationId = await this.findApplicationRegistrationId(
manifest.application.universalIdentifier,
);
@@ -7,4 +7,7 @@ import { Manifest } from 'twenty-shared/application';
export class ApplicationInput {
@Field(() => GraphQLJSON, { nullable: false })
manifest: Manifest;
@Field(() => Boolean, { nullable: true })
dryRun?: boolean;
}
@@ -6,6 +6,7 @@ import { isDefined } from 'twenty-shared/utils';
import { ComputeApplicationManifestAllUniversalFlatEntityMapsService } from 'src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service';
import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/build-from-to-all-universal-flat-entity-maps.util';
import { enrichSyncActionsWithFlatEntityInfo } from 'src/engine/core-modules/application/application-manifest/utils/enrich-sync-actions-with-flat-entity-info.util';
import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/get-application-sub-all-flat-entity-maps.util';
import {
ApplicationException,
@@ -162,10 +163,12 @@ export class ApplicationManifestMigrationService {
manifest,
workspaceId,
ownerFlatApplication,
dryRun = false,
}: {
manifest: Manifest;
workspaceId: string;
ownerFlatApplication: FlatApplication;
dryRun?: boolean;
}): Promise<{
workspaceMigration: WorkspaceMigration;
hasSchemaMetadataChanged: boolean;
@@ -225,6 +228,7 @@ export class ApplicationManifestMigrationService {
workspaceId,
dependencyAllFlatEntityMaps,
additionalCacheDataMaps: { featureFlagsMap },
dryRun,
},
);
@@ -236,17 +240,26 @@ export class ApplicationManifestMigrationService {
}
this.logger.log(
`Metadata migration completed for application ${ownerFlatApplication.universalIdentifier}`,
`Metadata migration ${dryRun ? 'plan computed (dry run)' : 'completed'} for application ${ownerFlatApplication.universalIdentifier}`,
);
await this.syncDefaultRoleAndSettingsCustomTab({
manifest,
workspaceId,
ownerFlatApplication,
});
if (!dryRun) {
await this.syncDefaultRoleAndSettingsCustomTab({
manifest,
workspaceId,
ownerFlatApplication,
});
}
return {
workspaceMigration: validateAndBuildResult.workspaceMigration,
workspaceMigration: {
...validateAndBuildResult.workspaceMigration,
actions: enrichSyncActionsWithFlatEntityInfo({
actions: validateAndBuildResult.workspaceMigration.actions,
fromAllFlatEntityMaps,
toAllUniversalFlatEntityMaps,
}),
},
hasSchemaMetadataChanged: validateAndBuildResult.hasSchemaMetadataChanged,
};
}
@@ -41,34 +41,63 @@ export class ApplicationSyncService {
workspaceId,
manifest,
applicationRegistrationId,
dryRun = false,
}: {
workspaceId: string;
manifest: Manifest;
applicationRegistrationId?: string;
dryRun?: boolean;
}): Promise<{
workspaceMigration: WorkspaceMigration;
hasSchemaMetadataChanged: boolean;
}> {
const application = await this.syncApplication({
workspaceId,
manifest,
applicationRegistrationId,
});
const ownerFlatApplication: FlatApplication = application;
const ownerFlatApplication: FlatApplication = dryRun
? await this.findInstalledApplicationOrThrow({ workspaceId, manifest })
: await this.syncApplication({
workspaceId,
manifest,
applicationRegistrationId,
});
const syncResult =
await this.applicationManifestMigrationService.syncMetadataFromManifest({
manifest,
workspaceId,
ownerFlatApplication,
dryRun,
});
this.logger.log('Application sync from manifest completed');
this.logger.log(
`Application sync from manifest ${dryRun ? 'plan computed (dry run)' : 'completed'}`,
);
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,107 @@
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { enrichSyncActionsWithFlatEntityInfo } from 'src/engine/core-modules/application/application-manifest/utils/enrich-sync-actions-with-flat-entity-info.util';
import { createEmptyAllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-all-flat-entity-maps.constant';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { type AllUniversalWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
const OBJECT_KEY = getMetadataFlatEntityMapsKey(
ALL_METADATA_NAME.objectMetadata,
);
const buildMapsWith = (
entities: { universalIdentifier: string; nameSingular: string }[],
): AllFlatEntityMaps => {
const maps = createEmptyAllFlatEntityMaps();
const objectMaps = maps[OBJECT_KEY] as unknown as {
byUniversalIdentifier: Record<string, unknown>;
};
for (const entity of entities) {
objectMaps.byUniversalIdentifier[entity.universalIdentifier] = entity;
}
return maps;
};
describe('enrichSyncActionsWithFlatEntityInfo', () => {
it('leaves create actions untouched (they already carry flatEntity)', () => {
const createAction = {
type: 'create',
metadataName: 'objectMetadata',
flatEntity: { universalIdentifier: 'c1', nameSingular: 'rocket' },
} as unknown as AllUniversalWorkspaceMigrationAction;
const [enriched] = enrichSyncActionsWithFlatEntityInfo({
actions: [createAction],
fromAllFlatEntityMaps: buildMapsWith([]),
toAllUniversalFlatEntityMaps: buildMapsWith([]),
});
expect(enriched).toBe(createAction);
});
it('attaches the manifest flatEntity to update actions', () => {
const updateAction = {
type: 'update',
metadataName: 'objectMetadata',
universalIdentifier: 'u1',
update: {},
} as unknown as AllUniversalWorkspaceMigrationAction;
const [enriched] = enrichSyncActionsWithFlatEntityInfo({
actions: [updateAction],
fromAllFlatEntityMaps: buildMapsWith([]),
toAllUniversalFlatEntityMaps: buildMapsWith([
{ universalIdentifier: 'u1', nameSingular: 'satellite' },
]),
});
expect(enriched).toMatchObject({
type: 'update',
metadataName: 'objectMetadata',
universalIdentifier: 'u1',
flatEntity: { universalIdentifier: 'u1', nameSingular: 'satellite' },
});
});
it('attaches the existing flatEntity to delete actions', () => {
const deleteAction = {
type: 'delete',
metadataName: 'objectMetadata',
universalIdentifier: 'd1',
} as unknown as AllUniversalWorkspaceMigrationAction;
const [enriched] = enrichSyncActionsWithFlatEntityInfo({
actions: [deleteAction],
fromAllFlatEntityMaps: buildMapsWith([
{ universalIdentifier: 'd1', nameSingular: 'legacy' },
]),
toAllUniversalFlatEntityMaps: buildMapsWith([]),
});
expect(enriched).toMatchObject({
type: 'delete',
metadataName: 'objectMetadata',
universalIdentifier: 'd1',
flatEntity: { universalIdentifier: 'd1', nameSingular: 'legacy' },
});
});
it('leaves the action untouched when the entity is not found in the maps', () => {
const deleteAction = {
type: 'delete',
metadataName: 'objectMetadata',
universalIdentifier: 'missing',
} as unknown as AllUniversalWorkspaceMigrationAction;
const [enriched] = enrichSyncActionsWithFlatEntityInfo({
actions: [deleteAction],
fromAllFlatEntityMaps: buildMapsWith([]),
toAllUniversalFlatEntityMaps: buildMapsWith([]),
});
expect(enriched).toBe(deleteAction);
});
});
@@ -0,0 +1,44 @@
import { isDefined } from 'twenty-shared/utils';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { type AllUniversalWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
// Create actions already carry their flatEntity, but update and delete actions
// only carry a universalIdentifier. This attaches the entity (resolved from the
// manifest for updates, from the existing workspace slice for deletes) so the
// CLI can render a human-readable label for every operation, not just creates.
export const enrichSyncActionsWithFlatEntityInfo = ({
actions,
fromAllFlatEntityMaps,
toAllUniversalFlatEntityMaps,
}: {
actions: AllUniversalWorkspaceMigrationAction[];
fromAllFlatEntityMaps: AllFlatEntityMaps;
toAllUniversalFlatEntityMaps: AllFlatEntityMaps;
}): AllUniversalWorkspaceMigrationAction[] =>
actions.map((action) => {
if (action.type === 'create') {
return action;
}
const flatEntityMapsKey = getMetadataFlatEntityMapsKey(action.metadataName);
const sourceAllFlatEntityMaps =
action.type === 'delete'
? fromAllFlatEntityMaps
: toAllUniversalFlatEntityMaps;
const flatEntity =
sourceAllFlatEntityMaps[flatEntityMapsKey].byUniversalIdentifier[
action.universalIdentifier
];
if (!isDefined(flatEntity)) {
return action;
}
return {
...action,
flatEntity,
} as unknown as AllUniversalWorkspaceMigrationAction;
});
@@ -358,6 +358,7 @@ export class WorkspaceMigrationValidateBuildAndRunService {
public async validateBuildAndRunWorkspaceMigrationFromTo(
args: WorkspaceMigrationOrchestratorBuildArgs & {
idByUniversalIdentifierByMetadataName?: IdByUniversalIdentifierByMetadataName;
dryRun?: boolean;
},
): Promise<
| WorkspaceMigrationOrchestratorFailedResult
@@ -365,7 +366,8 @@ export class WorkspaceMigrationValidateBuildAndRunService {
hasSchemaMetadataChanged: boolean;
})
> {
const { idByUniversalIdentifierByMetadataName, ...buildArgs } = args;
const { idByUniversalIdentifierByMetadataName, dryRun, ...buildArgs } =
args;
const validateAndBuildResult =
await this.workspaceMigrationBuildOrchestratorService
@@ -393,7 +395,7 @@ export class WorkspaceMigrationValidateBuildAndRunService {
})
: validateAndBuildResult.workspaceMigration;
if (workspaceMigration.actions.length === 0) {
if (dryRun === true || workspaceMigration.actions.length === 0) {
return {
status: 'success',
workspaceMigration,
@@ -0,0 +1,45 @@
import { type AllUniversalWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
import {
WorkspaceMigrationRunnerException,
WorkspaceMigrationRunnerExceptionCode,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception';
describe('WorkspaceMigrationRunnerException', () => {
it('includes the universal identifier of a failed create action in the message', () => {
const action = {
type: 'create',
metadataName: 'fieldMetadata',
flatEntity: {
universalIdentifier: '20202020-6736-4337-b5c4-8b39fae325a5',
},
} as unknown as AllUniversalWorkspaceMigrationAction;
const exception = new WorkspaceMigrationRunnerException({
code: WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED,
action,
errors: {},
});
expect(exception.message).toBe(
"Migration action 'create' for 'fieldMetadata' (universalIdentifier: 20202020-6736-4337-b5c4-8b39fae325a5) failed",
);
});
it('includes the universal identifier of a failed delete action in the message', () => {
const action = {
type: 'delete',
metadataName: 'pageLayout',
universalIdentifier: 'uid-page-layout',
} as unknown as AllUniversalWorkspaceMigrationAction;
const exception = new WorkspaceMigrationRunnerException({
code: WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED,
action,
errors: {},
});
expect(exception.message).toBe(
"Migration action 'delete' for 'pageLayout' (universalIdentifier: uid-page-layout) failed",
);
});
});
@@ -1,6 +1,6 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable, CustomError } from 'twenty-shared/utils';
import { assertUnreachable, CustomError, isDefined } from 'twenty-shared/utils';
import { type AllUniversalWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
@@ -34,6 +34,13 @@ export type WorkspaceMigrationRunnerExecutionErrors = {
actionTranspilation?: Error;
};
const getActionUniversalIdentifier = (
action: AllUniversalWorkspaceMigrationAction,
): string | undefined =>
action.type === 'create'
? action.flatEntity?.universalIdentifier
: action.universalIdentifier;
const {
// oxlint-disable-next-line unused-imports/no-unused-vars
EXECUTION_FAILED: WorkspaceMigrationRunnerExceptionExecutionFailedCode,
@@ -62,8 +69,13 @@ export class WorkspaceMigrationRunnerException extends CustomError {
constructor(args: WorkspaceMigrationRunnerExceptionConstructorArgs) {
if (args.code === WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED) {
const universalIdentifier = getActionUniversalIdentifier(args.action);
const identifierClause = isDefined(universalIdentifier)
? ` (universalIdentifier: ${universalIdentifier})`
: '';
super(
`Migration action '${args.action.type}' for '${args.action.metadataName}' failed`,
`Migration action '${args.action.type}' for '${args.action.metadataName}'${identifierClause} failed`,
);
this.code = args.code;
@@ -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`] = `
{
@@ -381,6 +381,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>,
@@ -0,0 +1,84 @@
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { type Manifest } from 'twenty-shared/application';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const buildManifest = (
overrides?: Partial<Pick<Manifest, 'objects' | 'fields'>>,
) => buildBaseManifest({ appId: TEST_APP_ID, roleId: TEST_ROLE_ID, overrides });
const findCustomObjectNames = async (): Promise<string[]> => {
const { objects } = await findManyObjectMetadata({
input: {
filter: { isCustom: { is: true } },
paging: { first: 100 },
},
gqlFields: 'id nameSingular',
expectToFail: false,
});
return objects.map((object) => object.nameSingular);
};
describe('Manifest sync - dry run', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Dry Run Test Application',
description: 'App for testing dry-run manifest sync',
sourcePath: 'dry-run-manifest-sync',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('returns the planned actions without applying them', async () => {
const ticketObject = buildDefaultObjectManifest({
nameSingular: 'dryRunTicket',
namePlural: 'dryRunTickets',
labelSingular: 'Dry Run Ticket',
labelPlural: 'Dry Run Tickets',
description: 'A support ticket',
});
await syncApplication({
manifest: buildManifest({ objects: [ticketObject] }),
expectToFail: false,
});
const invoiceObject = buildDefaultObjectManifest({
nameSingular: 'dryRunInvoice',
namePlural: 'dryRunInvoices',
labelSingular: 'Dry Run Invoice',
labelPlural: 'Dry Run Invoices',
description: 'A billing invoice',
});
const dryRunResponse = await syncApplication({
manifest: buildManifest({ objects: [ticketObject, invoiceObject] }),
dryRun: true,
expectToFail: false,
});
expect(dryRunResponse.errors).toBeUndefined();
expect(dryRunResponse.data.syncApplication.actions.length).toBeGreaterThan(
0,
);
const customObjectNames = await findCustomObjectNames();
expect(customObjectNames).toContain('dryRunTicket');
expect(customObjectNames).not.toContain('dryRunInvoice');
}, 60000);
});
@@ -3,12 +3,14 @@ import { type Manifest } from 'twenty-shared/application';
export const syncApplicationQueryFactory = ({
manifest,
dryRun,
}: {
manifest: Manifest;
dryRun?: boolean;
}) => ({
query: gql`
mutation SyncApplication($manifest: JSON!) {
syncApplication(manifest: $manifest) {
mutation SyncApplication($manifest: JSON!, $dryRun: Boolean) {
syncApplication(manifest: $manifest, dryRun: $dryRun) {
applicationUniversalIdentifier
actions
}
@@ -16,5 +18,6 @@ export const syncApplicationQueryFactory = ({
`,
variables: {
manifest,
dryRun,
},
});
@@ -14,15 +14,18 @@ export const syncApplication = async ({
manifest,
expectToFail = false,
token,
dryRun,
}: {
manifest: Manifest;
expectToFail?: boolean;
token?: string;
dryRun?: boolean;
}): CommonResponseBody<{
syncApplication: WorkspaceMigration;
}> => {
const graphqlOperation = syncApplicationQueryFactory({
manifest,
dryRun,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
@@ -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',