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
395 changed files with 3672 additions and 3451 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
</a>
</p>
<h2 align="center">The #1 Open-Source CRM</h2>
<h2 align="center" >The #1 Open-Source CRM</h2>
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.webp" width="12" height="12"/> Figma</a></p>
@@ -1434,6 +1434,11 @@ type EnterpriseSubscriptionStatusDTO {
isCancellationScheduled: Boolean!
}
type Analytics {
"""Boolean that confirms query was dispatched"""
success: Boolean!
}
type VerificationRecord {
type: String!
key: String!
@@ -2616,11 +2621,6 @@ type SendEmailOutput {
error: String
}
type Analytics {
"""Boolean that confirms query was dispatched"""
success: Boolean!
}
type EventLogRecord {
event: String!
timestamp: DateTime!
@@ -3007,6 +3007,7 @@ type Query {
): IndexConnection!
findManyAgents: [Agent!]!
findOneAgent(input: AgentIdInput!): Agent!
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
getRoles: [Role!]!
getToolIndex: [ToolIndexEntry!]!
getToolInputSchema(toolName: String!): JSON
@@ -3027,7 +3028,6 @@ type Query {
getViewGroup(id: String!): ViewGroup
myMessageFolders(messageChannelId: UUID): [MessageFolder!]!
myMessageChannels(connectedAccountId: UUID): [MessageChannel!]!
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
minimalMetadata: MinimalMetadata!
appConnections(filter: ListAppConnectionsInput): [AppConnection!]!
@@ -3200,6 +3200,8 @@ type Mutation {
updateApiKey(input: UpdateApiKeyInput!): ApiKey
revokeApiKey(input: RevokeApiKeyInput!): ApiKey
assignRoleToApiKey(apiKeyId: UUID!, roleId: UUID!): Boolean!
createObjectEvent(event: String!, recordId: UUID!, objectMetadataId: UUID!, properties: JSON): Analytics!
trackAnalytics(type: AnalyticsType!, name: String, event: String, properties: JSON): Analytics!
skipSyncEmailOnboardingStep: OnboardingStepSuccess!
skipBookOnboardingStep: OnboardingStepSuccess!
checkoutSession(recurringInterval: SubscriptionInterval!, plan: BillingPlanKey! = PRO, requirePaymentMethod: Boolean! = true, successUrlPath: String): BillingSession!
@@ -3252,6 +3254,7 @@ type Mutation {
createOneAgent(input: CreateAgentInput!): Agent!
updateOneAgent(input: UpdateAgentInput!): Agent!
deleteOneAgent(input: AgentIdInput!): Agent!
deleteConnectedAccount(id: UUID!): ConnectedAccountPublicDTO!
updateWorkspaceMemberRole(workspaceMemberId: UUID!, roleId: UUID!): WorkspaceMember!
createOneRole(createRoleInput: CreateRoleInput!): Role!
updateOneRole(updateRoleInput: UpdateRoleInput!): Role!
@@ -3280,7 +3283,6 @@ type Mutation {
updateMessageChannel(input: UpdateMessageChannelInput!): MessageChannel!
createEmailGroupChannel(input: CreateEmailGroupChannelInput!): CreateEmailGroupChannelOutput!
deleteEmailGroupChannel(id: UUID!): MessageChannel!
deleteConnectedAccount(id: UUID!): ConnectedAccountPublicDTO!
updateCalendarChannel(input: UpdateCalendarChannelInput!): CalendarChannel!
createChatThread: AgentChatThread!
sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileAttachments: [FileAttachmentInput!]): SendChatMessageResult!
@@ -3342,8 +3344,6 @@ type Mutation {
createSAMLIdentityProvider(input: SetupSAMLSsoInput!): SetupSso!
deleteSSOIdentityProvider(input: DeleteSsoInput!): DeleteSso!
editSSOIdentityProvider(input: EditSsoInput!): EditSso!
createObjectEvent(event: String!, recordId: UUID!, objectMetadataId: UUID!, properties: JSON): Analytics!
trackAnalytics(type: AnalyticsType!, name: String, event: String, properties: JSON): Analytics!
duplicateDashboard(id: UUID!): DuplicatedDashboard!
impersonate(userId: UUID!, workspaceId: UUID!): Impersonate!
sendEmail(input: SendEmailInput!): SendEmailOutput!
@@ -3716,6 +3716,11 @@ input RevokeApiKeyInput {
id: UUID!
}
enum AnalyticsType {
PAGEVIEW
TRACK
}
input CreateApprovedAccessDomainInput {
domain: String!
email: String!
@@ -4425,11 +4430,6 @@ input EditSsoInput {
status: SSOIdentityProviderStatus!
}
enum AnalyticsType {
PAGEVIEW
TRACK
}
input SendEmailInput {
connectedAccountId: String!
to: String!
@@ -4498,7 +4498,6 @@ type Subscription {
onEventSubscription(eventStreamId: String!): EventSubscription
logicFunctionLogs(input: LogicFunctionLogsInput!): LogicFunctionLogs!
onAgentChatEvent(threadId: UUID!): AgentChatEvent!
eventLogsLive(table: EventLogTable!): [EventLogRecord!]
}
input LogicFunctionLogsInput {
@@ -1091,6 +1091,12 @@ export interface EnterpriseSubscriptionStatusDTO {
__typename: 'EnterpriseSubscriptionStatusDTO'
}
export interface Analytics {
/** Boolean that confirms query was dispatched */
success: Scalars['Boolean']
__typename: 'Analytics'
}
export interface VerificationRecord {
type: Scalars['String']
key: Scalars['String']
@@ -2308,12 +2314,6 @@ export interface SendEmailOutput {
__typename: 'SendEmailOutput'
}
export interface Analytics {
/** Boolean that confirms query was dispatched */
success: Scalars['Boolean']
__typename: 'Analytics'
}
export interface EventLogRecord {
event: Scalars['String']
timestamp: Scalars['DateTime']
@@ -2613,6 +2613,7 @@ export interface Query {
indexMetadatas: IndexConnection
findManyAgents: Agent[]
findOneAgent: Agent
myConnectedAccounts: ConnectedAccountPublicDTO[]
getRoles: Role[]
getToolIndex: ToolIndexEntry[]
getToolInputSchema?: Scalars['JSON']
@@ -2624,7 +2625,6 @@ export interface Query {
getViewGroup?: ViewGroup
myMessageFolders: MessageFolder[]
myMessageChannels: MessageChannel[]
myConnectedAccounts: ConnectedAccountPublicDTO[]
myCalendarChannels: CalendarChannel[]
minimalMetadata: MinimalMetadata
appConnections: AppConnection[]
@@ -2724,6 +2724,8 @@ export interface Mutation {
updateApiKey?: ApiKey
revokeApiKey?: ApiKey
assignRoleToApiKey: Scalars['Boolean']
createObjectEvent: Analytics
trackAnalytics: Analytics
skipSyncEmailOnboardingStep: OnboardingStepSuccess
skipBookOnboardingStep: OnboardingStepSuccess
checkoutSession: BillingSession
@@ -2776,6 +2778,7 @@ export interface Mutation {
createOneAgent: Agent
updateOneAgent: Agent
deleteOneAgent: Agent
deleteConnectedAccount: ConnectedAccountPublicDTO
updateWorkspaceMemberRole: WorkspaceMember
createOneRole: Role
updateOneRole: Role
@@ -2804,7 +2807,6 @@ export interface Mutation {
updateMessageChannel: MessageChannel
createEmailGroupChannel: CreateEmailGroupChannelOutput
deleteEmailGroupChannel: MessageChannel
deleteConnectedAccount: ConnectedAccountPublicDTO
updateCalendarChannel: CalendarChannel
createChatThread: AgentChatThread
sendChatMessage: SendChatMessageResult
@@ -2866,8 +2868,6 @@ export interface Mutation {
createSAMLIdentityProvider: SetupSso
deleteSSOIdentityProvider: DeleteSso
editSSOIdentityProvider: EditSso
createObjectEvent: Analytics
trackAnalytics: Analytics
duplicateDashboard: DuplicatedDashboard
impersonate: Impersonate
sendEmail: SendEmailOutput
@@ -2892,17 +2892,16 @@ export interface Mutation {
__typename: 'Mutation'
}
export type WorkspaceMigrationActionType = 'delete' | 'create' | 'update'
export type AnalyticsType = 'PAGEVIEW' | 'TRACK'
export type WorkspaceMigrationActionType = 'delete' | 'create' | 'update'
export type FileFolder = 'ProfilePicture' | 'WorkspaceLogo' | 'Attachment' | 'PersonPicture' | 'CorePicture' | 'File' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'EmailAttachment' | 'AppTarball' | 'GeneratedSdkClient'
export interface Subscription {
onEventSubscription?: EventSubscription
logicFunctionLogs: LogicFunctionLogs
onAgentChatEvent: AgentChatEvent
eventLogsLive?: EventLogRecord[]
__typename: 'Subscription'
}
@@ -4050,6 +4049,13 @@ export interface EnterpriseSubscriptionStatusDTOGenqlSelection{
__scalar?: boolean | number
}
export interface AnalyticsGenqlSelection{
/** Boolean that confirms query was dispatched */
success?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface VerificationRecordGenqlSelection{
type?: boolean | number
key?: boolean | number
@@ -5358,13 +5364,6 @@ export interface SendEmailOutputGenqlSelection{
__scalar?: boolean | number
}
export interface AnalyticsGenqlSelection{
/** Boolean that confirms query was dispatched */
success?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EventLogRecordGenqlSelection{
event?: boolean | number
timestamp?: boolean | number
@@ -5672,6 +5671,7 @@ export interface QueryGenqlSelection{
filter: IndexFilter} })
findManyAgents?: AgentGenqlSelection
findOneAgent?: (AgentGenqlSelection & { __args: {input: AgentIdInput} })
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
getRoles?: RoleGenqlSelection
getToolIndex?: ToolIndexEntryGenqlSelection
getToolInputSchema?: { __args: {toolName: Scalars['String']} }
@@ -5689,7 +5689,6 @@ export interface QueryGenqlSelection{
getViewGroup?: (ViewGroupGenqlSelection & { __args: {id: Scalars['String']} })
myMessageFolders?: (MessageFolderGenqlSelection & { __args?: {messageChannelId?: (Scalars['UUID'] | null)} })
myMessageChannels?: (MessageChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
myCalendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
minimalMetadata?: MinimalMetadataGenqlSelection
appConnections?: (AppConnectionGenqlSelection & { __args?: {filter?: (ListAppConnectionsInput | null)} })
@@ -5812,6 +5811,8 @@ export interface MutationGenqlSelection{
updateApiKey?: (ApiKeyGenqlSelection & { __args: {input: UpdateApiKeyInput} })
revokeApiKey?: (ApiKeyGenqlSelection & { __args: {input: RevokeApiKeyInput} })
assignRoleToApiKey?: { __args: {apiKeyId: Scalars['UUID'], roleId: Scalars['UUID']} }
createObjectEvent?: (AnalyticsGenqlSelection & { __args: {event: Scalars['String'], recordId: Scalars['UUID'], objectMetadataId: Scalars['UUID'], properties?: (Scalars['JSON'] | null)} })
trackAnalytics?: (AnalyticsGenqlSelection & { __args: {type: AnalyticsType, name?: (Scalars['String'] | null), event?: (Scalars['String'] | null), properties?: (Scalars['JSON'] | null)} })
skipSyncEmailOnboardingStep?: OnboardingStepSuccessGenqlSelection
skipBookOnboardingStep?: OnboardingStepSuccessGenqlSelection
checkoutSession?: (BillingSessionGenqlSelection & { __args: {recurringInterval: SubscriptionInterval, plan: BillingPlanKey, requirePaymentMethod: Scalars['Boolean'], successUrlPath?: (Scalars['String'] | null)} })
@@ -5864,6 +5865,7 @@ export interface MutationGenqlSelection{
createOneAgent?: (AgentGenqlSelection & { __args: {input: CreateAgentInput} })
updateOneAgent?: (AgentGenqlSelection & { __args: {input: UpdateAgentInput} })
deleteOneAgent?: (AgentGenqlSelection & { __args: {input: AgentIdInput} })
deleteConnectedAccount?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
updateWorkspaceMemberRole?: (WorkspaceMemberGenqlSelection & { __args: {workspaceMemberId: Scalars['UUID'], roleId: Scalars['UUID']} })
createOneRole?: (RoleGenqlSelection & { __args: {createRoleInput: CreateRoleInput} })
updateOneRole?: (RoleGenqlSelection & { __args: {updateRoleInput: UpdateRoleInput} })
@@ -5892,7 +5894,6 @@ export interface MutationGenqlSelection{
updateMessageChannel?: (MessageChannelGenqlSelection & { __args: {input: UpdateMessageChannelInput} })
createEmailGroupChannel?: (CreateEmailGroupChannelOutputGenqlSelection & { __args: {input: CreateEmailGroupChannelInput} })
deleteEmailGroupChannel?: (MessageChannelGenqlSelection & { __args: {id: Scalars['UUID']} })
deleteConnectedAccount?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
updateCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {input: UpdateCalendarChannelInput} })
createChatThread?: AgentChatThreadGenqlSelection
sendChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], text: Scalars['String'], messageId: Scalars['UUID'], browsingContext?: (Scalars['JSON'] | null), modelId?: (Scalars['String'] | null), fileAttachments?: (FileAttachmentInput[] | null)} })
@@ -5954,8 +5955,6 @@ export interface MutationGenqlSelection{
createSAMLIdentityProvider?: (SetupSsoGenqlSelection & { __args: {input: SetupSAMLSsoInput} })
deleteSSOIdentityProvider?: (DeleteSsoGenqlSelection & { __args: {input: DeleteSsoInput} })
editSSOIdentityProvider?: (EditSsoGenqlSelection & { __args: {input: EditSsoInput} })
createObjectEvent?: (AnalyticsGenqlSelection & { __args: {event: Scalars['String'], recordId: Scalars['UUID'], objectMetadataId: Scalars['UUID'], properties?: (Scalars['JSON'] | null)} })
trackAnalytics?: (AnalyticsGenqlSelection & { __args: {type: AnalyticsType, name?: (Scalars['String'] | null), event?: (Scalars['String'] | null), properties?: (Scalars['JSON'] | null)} })
duplicateDashboard?: (DuplicatedDashboardGenqlSelection & { __args: {id: Scalars['UUID']} })
impersonate?: (ImpersonateGenqlSelection & { __args: {userId: Scalars['UUID'], workspaceId: Scalars['UUID']} })
sendEmail?: (SendEmailOutputGenqlSelection & { __args: {input: SendEmailInput} })
@@ -6357,7 +6356,6 @@ export interface SubscriptionGenqlSelection{
onEventSubscription?: (EventSubscriptionGenqlSelection & { __args: {eventStreamId: Scalars['String']} })
logicFunctionLogs?: (LogicFunctionLogsGenqlSelection & { __args: {input: LogicFunctionLogsInput} })
onAgentChatEvent?: (AgentChatEventGenqlSelection & { __args: {threadId: Scalars['UUID']} })
eventLogsLive?: (EventLogRecordGenqlSelection & { __args: {table: EventLogTable} })
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -7005,6 +7003,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const Analytics_possibleTypes: string[] = ['Analytics']
export const isAnalytics = (obj?: { __typename?: any } | null): obj is Analytics => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAnalytics"')
return Analytics_possibleTypes.includes(obj.__typename)
}
const VerificationRecord_possibleTypes: string[] = ['VerificationRecord']
export const isVerificationRecord = (obj?: { __typename?: any } | null): obj is VerificationRecord => {
if (!obj?.__typename) throw new Error('__typename is missing in "isVerificationRecord"')
@@ -8133,14 +8139,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const Analytics_possibleTypes: string[] = ['Analytics']
export const isAnalytics = (obj?: { __typename?: any } | null): obj is Analytics => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAnalytics"')
return Analytics_possibleTypes.includes(obj.__typename)
}
const EventLogRecord_possibleTypes: string[] = ['EventLogRecord']
export const isEventLogRecord = (obj?: { __typename?: any } | null): obj is EventLogRecord => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEventLogRecord"')
@@ -8992,17 +8990,17 @@ export const enumUsageOperationType = {
WEB_SEARCH: 'WEB_SEARCH' as const
}
export const enumAnalyticsType = {
PAGEVIEW: 'PAGEVIEW' as const,
TRACK: 'TRACK' as const
}
export const enumWorkspaceMigrationActionType = {
delete: 'delete' as const,
create: 'create' as const,
update: 'update' as const
}
export const enumAnalyticsType = {
PAGEVIEW: 'PAGEVIEW' as const,
TRACK: 'TRACK' as const
}
export const enumFileFolder = {
ProfilePicture: 'ProfilePicture' as const,
WorkspaceLogo: 'WorkspaceLogo' as const,
File diff suppressed because it is too large Load Diff
+39 -3
View File
@@ -6,10 +6,10 @@ services:
volumes:
- server-local-data:/app/packages/twenty-server/.local-storage
ports:
- "3010:3000"
- "3000:3000"
environment:
NODE_PORT: 3000
PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:-postgres}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/${PG_DATABASE_NAME}
PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:-postgres}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/default
SERVER_URL: ${SERVER_URL}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
DISABLE_DB_MIGRATIONS: ${DISABLE_DB_MIGRATIONS}
@@ -46,6 +46,11 @@ services:
# EMAIL_SMTP_USER: ${EMAIL_SMTP_USER:-}
# EMAIL_SMTP_PASSWORD: ${EMAIL_SMTP_PASSWORD:-}
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: curl --fail http://localhost:3000/healthz
interval: 5s
@@ -59,7 +64,7 @@ services:
- server-local-data:/app/packages/twenty-server/.local-storage
command: ["yarn", "worker:prod"]
environment:
PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:-postgres}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/${PG_DATABASE_NAME}
PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:-postgres}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/default
SERVER_URL: ${SERVER_URL}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
DISABLE_DB_MIGRATIONS: "true" # it already runs on the server
@@ -96,7 +101,38 @@ services:
# EMAIL_SMTP_USER: ${EMAIL_SMTP_USER:-}
# EMAIL_SMTP_PASSWORD: ${EMAIL_SMTP_PASSWORD:-}
depends_on:
db:
condition: service_healthy
server:
condition: service_healthy
restart: always
db:
image: postgres:16
volumes:
- db-data:/var/lib/postgresql/data
environment:
POSTGRES_DB: ${PG_DATABASE_NAME:-default}
POSTGRES_PASSWORD: ${PG_DATABASE_PASSWORD:-postgres}
POSTGRES_USER: ${PG_DATABASE_USER:-postgres}
healthcheck:
test: pg_isready -U ${PG_DATABASE_USER:-postgres} -h localhost -d postgres
interval: 5s
timeout: 5s
retries: 10
restart: always
redis:
image: redis
restart: always
command: ["--maxmemory-policy", "noeviction"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
volumes:
db-data:
server-local-data:
@@ -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"
]
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 124 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 70 KiB

@@ -4933,18 +4933,12 @@ export type StandardOverrides = {
export type Subscription = {
__typename?: 'Subscription';
eventLogsLive?: Maybe<Array<EventLogRecord>>;
logicFunctionLogs: LogicFunctionLogs;
onAgentChatEvent: AgentChatEvent;
onEventSubscription?: Maybe<EventSubscription>;
};
export type SubscriptionEventLogsLiveArgs = {
table: EventLogTable;
};
export type SubscriptionLogicFunctionLogsArgs = {
input: LogicFunctionLogsInput;
};
@@ -7410,14 +7404,7 @@ export type EventLogsQueryVariables = Exact<{
}>;
export type EventLogsQuery = { __typename?: 'Query', eventLogs: { __typename?: 'EventLogQueryResult', totalCount: number, records: Array<{ __typename?: 'EventLogRecord', event: string, timestamp: string, userId?: string | null, properties?: any | null, recordId?: string | null, objectMetadataId?: string | null }>, pageInfo: { __typename?: 'EventLogPageInfo', endCursor?: string | null, hasNextPage: boolean } } };
export type EventLogsLiveSubscriptionVariables = Exact<{
table: EventLogTable;
}>;
export type EventLogsLiveSubscription = { __typename?: 'Subscription', eventLogsLive?: Array<{ __typename?: 'EventLogRecord', event: string, timestamp: string, userId?: string | null, properties?: any | null, recordId?: string | null, objectMetadataId?: string | null }> | null };
export type EventLogsQuery = { __typename?: 'Query', eventLogs: { __typename?: 'EventLogQueryResult', totalCount: number, records: Array<{ __typename?: 'EventLogRecord', event: string, timestamp: string, userId?: string | null, properties?: any | null, recordId?: string | null, objectMetadataId?: string | null, isCustom?: boolean | null }>, pageInfo: { __typename?: 'EventLogPageInfo', endCursor?: string | null, hasNextPage: boolean } } };
export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{
input: UpdateLabPublicFeatureFlagInput;
@@ -8268,8 +8255,7 @@ export const SetEnterpriseKeyDocument = {"kind":"Document","definitions":[{"kind
export const EnterpriseCheckoutSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EnterpriseCheckoutSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"billingInterval"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enterpriseCheckoutSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"billingInterval"},"value":{"kind":"Variable","name":{"kind":"Name","value":"billingInterval"}}}]}]}}]} as unknown as DocumentNode<EnterpriseCheckoutSessionQuery, EnterpriseCheckoutSessionQueryVariables>;
export const EnterprisePortalSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EnterprisePortalSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"returnUrlPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enterprisePortalSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"returnUrlPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"returnUrlPath"}}}]}]}}]} as unknown as DocumentNode<EnterprisePortalSessionQuery, EnterprisePortalSessionQueryVariables>;
export const EnterpriseSubscriptionStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EnterpriseSubscriptionStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enterpriseSubscriptionStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"licensee"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"cancelAt"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"isCancellationScheduled"}}]}}]}}]} as unknown as DocumentNode<EnterpriseSubscriptionStatusQuery, EnterpriseSubscriptionStatusQueryVariables>;
export const EventLogsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EventLogs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EventLogQueryInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"eventLogs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"records"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"event"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"properties"}},{"kind":"Field","name":{"kind":"Name","value":"recordId"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}}]}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endCursor"}},{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}}]}}]}}]}}]} as unknown as DocumentNode<EventLogsQuery, EventLogsQueryVariables>;
export const EventLogsLiveDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"EventLogsLive"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"table"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EventLogTable"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"eventLogsLive"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"table"},"value":{"kind":"Variable","name":{"kind":"Name","value":"table"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"event"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"properties"}},{"kind":"Field","name":{"kind":"Name","value":"recordId"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}}]}}]}}]} as unknown as DocumentNode<EventLogsLiveSubscription, EventLogsLiveSubscriptionVariables>;
export const EventLogsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EventLogs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EventLogQueryInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"eventLogs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"records"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"event"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"properties"}},{"kind":"Field","name":{"kind":"Name","value":"recordId"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}}]}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endCursor"}},{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}}]}}]}}]}}]} as unknown as DocumentNode<EventLogsQuery, EventLogsQueryVariables>;
export const UpdateLabPublicFeatureFlagDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateLabPublicFeatureFlag"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateLabPublicFeatureFlagInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateLabPublicFeatureFlag"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}}]}}]} as unknown as DocumentNode<UpdateLabPublicFeatureFlagMutation, UpdateLabPublicFeatureFlagMutationVariables>;
export const UploadWorkspaceMemberProfilePictureDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UploadWorkspaceMemberProfilePicture"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"file"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Upload"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uploadWorkspaceMemberProfilePicture"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"file"},"value":{"kind":"Variable","name":{"kind":"Name","value":"file"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode<UploadWorkspaceMemberProfilePictureMutation, UploadWorkspaceMemberProfilePictureMutationVariables>;
export const UpdateUserEmailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateUserEmail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"newEmail"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"verifyEmailRedirectPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateUserEmail"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"newEmail"},"value":{"kind":"Variable","name":{"kind":"Name","value":"newEmail"}}},{"kind":"Argument","name":{"kind":"Name","value":"verifyEmailRedirectPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"verifyEmailRedirectPath"}}}]}]}}]} as unknown as DocumentNode<UpdateUserEmailMutation, UpdateUserEmailMutationVariables>;
@@ -8,7 +8,6 @@ import { originalDragSelectionComponentState } from '@/object-record/record-drag
import { RECORD_INDEX_REMOVE_SORTING_MODAL_ID } from '@/object-record/record-index/constants/RecordIndexRemoveSortingModalId';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { getBoardCardDropBehavior } from '@/object-record/record-board/utils/getBoardCardDropBehavior';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useAtomComponentSelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorCallbackState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
@@ -83,13 +82,8 @@ export const RecordBoardDragDropContext = ({
}
const existingRecordSorts = store.get(currentRecordSorts);
const boardCardDropBehavior = getBoardCardDropBehavior({
hasRecordSorts: existingRecordSorts.length > 0,
sourceDroppableId: result.source.droppableId,
destinationDroppableId: result.destination.droppableId,
});
if (boardCardDropBehavior.shouldBlockDrop) {
if (existingRecordSorts.length > 0) {
store.set(isRecordBoardDropProcessingCallbackState, false);
endRecordDrag();
openModal(RECORD_INDEX_REMOVE_SORTING_MODAL_ID);
@@ -97,9 +91,7 @@ export const RecordBoardDragDropContext = ({
}
try {
processBoardCardDrop(result, originalDragSelection, {
shouldUpdatePosition: boardCardDropBehavior.shouldUpdatePosition,
});
processBoardCardDrop(result, originalDragSelection);
} catch (error) {
store.set(isRecordBoardDropProcessingCallbackState, false);
endRecordDrag();
@@ -1,42 +0,0 @@
import { getBoardCardDropBehavior } from '@/object-record/record-board/utils/getBoardCardDropBehavior';
describe('getBoardCardDropBehavior', () => {
it('should block same-column drops when record sorting is active', () => {
expect(
getBoardCardDropBehavior({
hasRecordSorts: true,
sourceDroppableId: 'new',
destinationDroppableId: 'new',
}),
).toEqual({
shouldBlockDrop: true,
shouldUpdatePosition: false,
});
});
it('should allow cross-column drops without position updates when record sorting is active', () => {
expect(
getBoardCardDropBehavior({
hasRecordSorts: true,
sourceDroppableId: 'new',
destinationDroppableId: 'won',
}),
).toEqual({
shouldBlockDrop: false,
shouldUpdatePosition: false,
});
});
it('should allow drops with position updates when record sorting is inactive', () => {
expect(
getBoardCardDropBehavior({
hasRecordSorts: false,
sourceDroppableId: 'new',
destinationDroppableId: 'won',
}),
).toEqual({
shouldBlockDrop: false,
shouldUpdatePosition: true,
});
});
});
@@ -1,17 +0,0 @@
export const getBoardCardDropBehavior = ({
hasRecordSorts,
sourceDroppableId,
destinationDroppableId,
}: {
hasRecordSorts: boolean;
sourceDroppableId: string;
destinationDroppableId: string;
}) => {
const isMovingInsideSameRecordGroup =
sourceDroppableId === destinationDroppableId;
return {
shouldBlockDrop: hasRecordSorts && isMovingInsideSameRecordGroup,
shouldUpdatePosition: !hasRecordSorts,
};
};
@@ -40,15 +40,9 @@ export const useProcessBoardCardDrop = () => {
);
const processBoardCardDrop = useCallback(
(
boardCardDropResult: DropResult,
selectedRecordIds: string[],
options?: { shouldUpdatePosition?: boolean },
) => {
(boardCardDropResult: DropResult, selectedRecordIds: string[]) => {
if (!isDefined(selectFieldMetadataItem)) return;
const shouldUpdatePosition = options?.shouldUpdatePosition ?? true;
processGroupDrop({
groupDropResult: boardCardDropResult,
store,
@@ -57,10 +51,7 @@ export const useProcessBoardCardDrop = () => {
recordIndexRecordIdsByGroupCallbackFamilyState,
onUpdateRecord: ({ recordId, position }, targetRecordGroupValue) => {
updateDroppedRecordOnBoard(
{
recordId,
position: shouldUpdatePosition ? position : undefined,
},
{ recordId, position },
targetRecordGroupValue,
);
},
@@ -45,6 +45,10 @@ export const useUpdateDroppedRecordOnBoard = () => {
recordStoreFamilyState.atomFamily(recordId),
) as Record<string, unknown> | null | undefined;
if (!isDefined(newPosition)) {
return;
}
if (!isDefined(initialRecord)) {
return;
}
@@ -90,10 +94,7 @@ export const useUpdateDroppedRecordOnBoard = () => {
const isSamePosition = initialRecord.position === newPosition;
if (
movingInsideSameRecordGroup &&
(!isDefined(newPosition) || isSamePosition)
) {
if (movingInsideSameRecordGroup && isSamePosition) {
return;
}
@@ -125,32 +126,25 @@ export const useUpdateDroppedRecordOnBoard = () => {
);
}
if (isDefined(newPosition)) {
const targetGroupRecordsWithIds = extractRecordPositions(
currentRecordIdsInTargetRecordGroup,
store,
);
const targetGroupRecordsWithIds = extractRecordPositions(
currentRecordIdsInTargetRecordGroup,
store,
);
const newTargetRecordGroupWithIds = [
...targetGroupRecordsWithIds,
{
id: recordId,
position: newPosition,
},
];
const newTargetRecordGroupWithIds = [
...targetGroupRecordsWithIds,
{
id: recordId,
position: newPosition,
},
];
newTargetRecordGroupWithIds.sort(sortByProperty('position', 'asc'));
newTargetRecordGroupWithIds.sort(sortByProperty('position', 'asc'));
store.set(
recordIndexRecordIdsByGroupCallbackFamilyState(targetRecordGroupId),
newTargetRecordGroupWithIds.map((record) => record.id),
);
} else {
store.set(
recordIndexRecordIdsByGroupCallbackFamilyState(targetRecordGroupId),
[...currentRecordIdsInTargetRecordGroup, recordId],
);
}
store.set(
recordIndexRecordIdsByGroupCallbackFamilyState(targetRecordGroupId),
newTargetRecordGroupWithIds.map((record) => record.id),
);
upsertRecordsInStore({
partialRecords: [
@@ -161,7 +155,7 @@ export const useUpdateDroppedRecordOnBoard = () => {
(initialRecord as { __typename?: string })?.__typename ??
'Record',
[selectFieldMetadataItem.name]: targetRecordGroupValue,
...(isDefined(newPosition) && { position: newPosition }),
position: newPosition,
} as ObjectRecord,
],
});
@@ -170,7 +164,7 @@ export const useUpdateDroppedRecordOnBoard = () => {
idToUpdate: recordId,
updateOneRecordInput: {
[selectFieldMetadataItem.name]: targetRecordGroupValue,
...(isDefined(newPosition) && { position: newPosition }),
position: newPosition,
},
});
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

@@ -1 +1,4 @@
export { SettingsDatePickerInput as EventLogDatePickerInput } from '@/settings/components/SettingsDatePickerInput';
export {
SettingsDatePickerInput as EventLogDatePickerInput,
type SettingsDatePickerInputProps as EventLogDatePickerInputProps,
} from '@/settings/components/SettingsDatePickerInput';
@@ -12,16 +12,18 @@ import { TableRow } from '@/ui/layout/table/components/TableRow';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import {
type EventLogRecord,
type EventLogTable,
EventLogTable,
} from '~/generated-metadata/graphql';
import {
type ColumnConfig,
getColumnsForEventLogTable,
} from '@/settings/event-logs/utils/getColumnsForEventLogTable';
import { EventLogJsonCell } from '@/settings/event-logs/components/EventLogJsonCell';
type EventLogResultsTableProps = {
records: EventLogRecord[];
@@ -104,6 +106,9 @@ export const EventLogResultsTable = ({
const { theme } = useContext(ThemeContext);
const { t } = useLingui();
const showObjectEventColumns = selectedTable === EventLogTable.OBJECT_EVENT;
const showApplicationLogColumns =
selectedTable === EventLogTable.APPLICATION_LOG;
const baseColumns = getColumnsForEventLogTable(selectedTable);
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() =>
@@ -112,6 +117,7 @@ export const EventLogResultsTable = ({
const [resizingColumn, setResizingColumn] = useState<string | null>(null);
// Reset column widths when switching tables to avoid undefined widths for new columns
useEffect(() => {
setColumnWidths(
Object.fromEntries(baseColumns.map((col) => [col.id, col.defaultWidth])),
@@ -236,21 +242,85 @@ export const EventLogResultsTable = ({
</StyledResizableHeaderContainer>
))}
</TableRow>
{records.map((record) => (
{records.map((record, index) => (
<TableRow
key={`${record.timestamp}-${record.event}`}
key={`${record.timestamp}-${record.event}-${index}`}
gridTemplateColumns={gridTemplateColumns}
>
{baseColumns.map((column) => (
<TableCell
key={column.id}
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{column.renderCell(record)}
</TableCell>
))}
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.event}
</TableCell>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{beautifyPastDateRelativeToNow(record.timestamp)}
</TableCell>
{showApplicationLogColumns ? (
<>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.properties?.level ?? '-'}
</TableCell>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.properties?.message ?? '-'}
</TableCell>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.properties?.executionId ?? '-'}
</TableCell>
</>
) : (
<>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.userId ?? '-'}
</TableCell>
{showObjectEventColumns && (
<>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.recordId ?? '-'}
</TableCell>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.objectMetadataId ?? '-'}
</TableCell>
</>
)}
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
<EventLogJsonCell value={record.properties} />
</TableCell>
</>
)}
</TableRow>
))}
</Table>
@@ -1,5 +1,3 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Select } from '@/ui/input/components/Select';
@@ -10,26 +8,34 @@ type EventLogTableSelectorProps = {
onChange: (value: EventLogTable) => void;
};
const TABLE_LABELS: Record<EventLogTable, MessageDescriptor> = {
[EventLogTable.PAGEVIEW]: msg`Page Views`,
[EventLogTable.WORKSPACE_EVENT]: msg`Workspace Events`,
[EventLogTable.OBJECT_EVENT]: msg`Object Events`,
[EventLogTable.USAGE_EVENT]: msg`Usage Events`,
[EventLogTable.APPLICATION_LOG]: msg`Application Logs`,
};
export const EventLogTableSelector = ({
value,
onChange,
}: EventLogTableSelectorProps) => {
const { t } = useLingui();
const options = (
Object.entries(TABLE_LABELS) as [EventLogTable, MessageDescriptor][]
).map(([table, label]) => ({
value: table,
label: t(label),
}));
const options = [
{
value: EventLogTable.PAGEVIEW,
label: t`Page Views`,
},
{
value: EventLogTable.WORKSPACE_EVENT,
label: t`Workspace Events`,
},
{
value: EventLogTable.OBJECT_EVENT,
label: t`Object Events`,
},
{
value: EventLogTable.USAGE_EVENT,
label: t`Usage Events`,
},
{
value: EventLogTable.APPLICATION_LOG,
label: t`Application Logs`,
},
];
return (
<Select
@@ -1,51 +1,24 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { billingState } from '@/client-config/states/billingState';
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
import { SettingsEmptyPlaceholder } from '@/settings/components/SettingsEmptyPlaceholder';
import { SettingsOptionCardContentButton } from '@/settings/components/SettingsOptions/SettingsOptionCardContentButton';
import { SettingsEnterpriseFeatureGateCard } from '@/settings/components/SettingsEnterpriseFeatureGateCard';
import { EventLogFilters } from '@/settings/event-logs/components/EventLogFilters';
import { EventLogResultsTable } from '@/settings/event-logs/components/EventLogResultsTable';
import { EventLogTableSelector } from '@/settings/event-logs/components/EventLogTableSelector';
import { useEventLogsLiveStream } from '@/settings/event-logs/hooks/useEventLogsLiveStream';
import { useEventLogs } from '@/settings/event-logs/hooks/useQueryEventLogs';
import { type EventLogFiltersState } from '@/settings/event-logs/types/EventLogFiltersState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
IconArrowUp,
IconLock,
IconPlayerPause,
IconPlayerPlay,
} from 'twenty-ui/display';
import { Button, IconButton } from 'twenty-ui/input';
import { IconRefresh } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { Card } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
BillingEntitlementKey,
EventLogTable,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
const StyledRoot = styled.div`
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
gap: ${themeCssVariables.spacing[6]};
margin: 0 auto;
max-width: 760px;
min-height: 0;
padding: ${themeCssVariables.spacing[6]} ${themeCssVariables.spacing[8]}
${themeCssVariables.spacing[8]};
width: 100%;
`;
import { EventLogTable } from '~/generated-metadata/graphql';
const StyledCardContent = styled.div`
display: flex;
@@ -67,10 +40,8 @@ const StyledSelectorGrow = styled.div`
const StyledResults = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
min-height: 0;
`;
const StyledRecordCount = styled.span`
@@ -79,9 +50,10 @@ const StyledRecordCount = styled.span`
font-size: ${themeCssVariables.font.size.sm};
`;
// The results table scrolls internally and loads more as you reach the bottom,
// so it needs a bounded height.
const StyledTableWrapper = styled.div`
flex: 1;
min-height: 0;
height: 480px;
overflow: hidden;
`;
@@ -92,64 +64,45 @@ export const SettingsLogs = () => {
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const isClickHouseConfigured = useAtomStateValue(isClickHouseConfiguredState);
const billing = useAtomStateValue(billingState);
const navigateSettings = useNavigateSettings();
const isBillingEnabled = billing?.isBillingEnabled ?? false;
const hasAuditLogsEntitlement =
currentWorkspace?.billingEntitlements?.some(
(entitlement) =>
entitlement.key === BillingEntitlementKey.AUDIT_LOGS &&
entitlement.value,
) === true;
const hasEnterpriseAccess =
currentWorkspace?.hasValidSignedEnterpriseKey === true;
const [selectedTable, setSelectedTable] = useState<EventLogTable>(
EventLogTable.PAGEVIEW,
);
const [filters, setFilters] = useState<EventLogFiltersState>({});
const [isPaused, setIsPaused] = useState(false);
const isApplicationLog = selectedTable === EventLogTable.APPLICATION_LOG;
const canQuery =
isClickHouseConfigured && (isApplicationLog || hasAuditLogsEntitlement);
isClickHouseConfigured && (isApplicationLog || hasEnterpriseAccess);
const { records, totalCount, hasNextPage, loading, error, loadMore } =
useEventLogs(
{
table: selectedTable,
filters: {
eventType: filters.eventType,
userWorkspaceId: filters.userWorkspaceId,
dateRange: filters.dateRange
? {
start: filters.dateRange.start?.toISOString(),
end: filters.dateRange.end?.toISOString(),
}
: undefined,
recordId: filters.recordId,
objectMetadataId: filters.objectMetadataId,
},
first: RECORDS_PER_PAGE,
const {
records,
totalCount,
hasNextPage,
loading,
error,
refetch,
loadMore,
} = useEventLogs(
{
table: selectedTable,
filters: {
eventType: filters.eventType,
userWorkspaceId: filters.userWorkspaceId,
dateRange: filters.dateRange
? {
start: filters.dateRange.start?.toISOString(),
end: filters.dateRange.end?.toISOString(),
}
: undefined,
recordId: filters.recordId,
objectMetadataId: filters.objectMetadataId,
},
{ skip: !canQuery },
);
const hasActiveFilters =
isDefined(filters.eventType) ||
isDefined(filters.userWorkspaceId) ||
isDefined(filters.recordId) ||
isDefined(filters.objectMetadataId) ||
isDefined(filters.dateRange?.start) ||
isDefined(filters.dateRange?.end);
const liveRecords = useEventLogsLiveStream({
table: selectedTable,
enabled: !isPaused && !hasActiveFilters && canQuery,
});
const displayedRecords = useMemo(
() => [...liveRecords, ...records],
[liveRecords, records],
first: RECORDS_PER_PAGE,
},
{ skip: !canQuery },
);
const handleTableChange = (table: EventLogTable) => {
@@ -161,63 +114,39 @@ export const SettingsLogs = () => {
setFilters(newFilters);
};
const renderUpgradeCard = () => (
<Card rounded>
<SettingsOptionCardContentButton
Icon={IconLock}
title={t`Upgrade to access audit logs`}
description={t`Only application logs are available on your current plan. Other log types require an Enterprise subscription.`}
Button={
<Button
title={t`Upgrade`}
variant="primary"
accent="blue"
size="small"
Icon={IconArrowUp}
onClick={() =>
navigateSettings(
isBillingEnabled
? SettingsPath.Billing
: SettingsPath.AdminPanelEnterprise,
)
}
/>
}
/>
</Card>
);
const renderResults = () => {
if (!isApplicationLog && !hasAuditLogsEntitlement) {
return renderUpgradeCard();
if (!isApplicationLog && !hasEnterpriseAccess) {
return (
<SettingsEnterpriseFeatureGateCard
title={t`Enterprise feature`}
description={t`Upgrade to Enterprise to access this log type.`}
buttonTitle={t`Activate`}
/>
);
}
if (!isClickHouseConfigured) {
return (
<SettingsEmptyPlaceholder>
{t`Logs require ClickHouse to be configured. Please contact your administrator.`}
{t`Audit logs require ClickHouse to be configured. Please contact your administrator.`}
</SettingsEmptyPlaceholder>
);
}
if (isDefined(error)) {
if (isGraphqlErrorOfType(error, 'NO_ENTITLEMENT')) {
return renderUpgradeCard();
}
return (
<SettingsEmptyPlaceholder>
{t`Something went wrong while loading logs. Please try again.`}
{t`Something went wrong while loading audit logs. Please try again.`}
</SettingsEmptyPlaceholder>
);
}
return (
<StyledResults>
<StyledRecordCount>{t`${displayedRecords.length} of ${totalCount + liveRecords.length}`}</StyledRecordCount>
<StyledRecordCount>{t`${records.length} of ${totalCount}`}</StyledRecordCount>
<StyledTableWrapper>
<EventLogResultsTable
records={displayedRecords}
records={records}
loading={loading}
hasNextPage={hasNextPage}
onLoadMore={loadMore}
@@ -229,7 +158,7 @@ export const SettingsLogs = () => {
};
return (
<StyledRoot>
<>
<Card rounded fullWidth>
<StyledCardContent>
<StyledSelectorRow>
@@ -239,15 +168,17 @@ export const SettingsLogs = () => {
onChange={handleTableChange}
/>
</StyledSelectorGrow>
{canQuery && (
<IconButton
Icon={isPaused ? IconPlayerPlay : IconPlayerPause}
variant="secondary"
size="medium"
ariaLabel={isPaused ? t`Resume` : t`Pause`}
onClick={() => setIsPaused((previous) => !previous)}
/>
)}
<IconButton
Icon={IconRefresh}
variant="secondary"
size="medium"
ariaLabel={t`Refresh`}
onClick={() => {
if (canQuery) {
void refetch();
}
}}
/>
</StyledSelectorRow>
<EventLogFilters
table={selectedTable}
@@ -258,6 +189,6 @@ export const SettingsLogs = () => {
</Card>
{renderResults()}
</StyledRoot>
</>
);
};
@@ -10,6 +10,7 @@ export const GET_EVENT_LOGS = gql`
properties
recordId
objectMetadataId
isCustom
}
totalCount
pageInfo {
@@ -1,14 +0,0 @@
import { gql } from '@apollo/client';
export const EVENT_LOGS_LIVE_SUBSCRIPTION = gql`
subscription EventLogsLive($table: EventLogTable!) {
eventLogsLive(table: $table) {
event
timestamp
userId
properties
recordId
objectMetadataId
}
}
`;
@@ -1,68 +0,0 @@
import { print, type ExecutionResult } from 'graphql';
import { useEffect, useState } from 'react';
import { EVENT_LOGS_LIVE_SUBSCRIPTION } from '@/settings/event-logs/graphql/subscriptions/EventLogsLiveSubscription';
import { sseClientState } from '@/sse-db-event/states/sseClientState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { captureException } from '@sentry/react';
import { isDefined } from 'twenty-shared/utils';
import {
type EventLogRecord,
type EventLogTable,
} from '~/generated-metadata/graphql';
type EventLogsLivePayload = {
eventLogsLive: EventLogRecord[] | null;
};
const EVENT_LOGS_LIVE_SUBSCRIPTION_QUERY = print(EVENT_LOGS_LIVE_SUBSCRIPTION);
export const useEventLogsLiveStream = ({
table,
enabled,
}: {
table: EventLogTable;
enabled: boolean;
}): EventLogRecord[] => {
const sseClient = useAtomStateValue(sseClientState);
const [liveRecords, setLiveRecords] = useState<EventLogRecord[]>([]);
useEffect(() => {
setLiveRecords([]);
}, [table]);
useEffect(() => {
if (!enabled) {
setLiveRecords([]);
}
}, [enabled]);
useEffect(() => {
if (!enabled || !isDefined(sseClient)) {
return;
}
const dispose = sseClient.subscribe<EventLogsLivePayload>(
{
query: EVENT_LOGS_LIVE_SUBSCRIPTION_QUERY,
variables: { table },
},
{
next: (value: ExecutionResult<EventLogsLivePayload>) => {
const incoming = value.data?.eventLogsLive;
if (isDefined(incoming) && incoming.length > 0) {
setLiveRecords((previous) => [...incoming, ...previous]);
}
},
error: (error) => captureException(error),
complete: () => {},
},
);
return () => dispose();
}, [enabled, sseClient, table]);
return liveRecords;
};
@@ -20,7 +20,7 @@ export const useEventLogs = (
input: EventLogQueryInput,
options?: { skip?: boolean },
) => {
const { data, loading, error, fetchMore } = useQuery<
const { data, loading, error, refetch, fetchMore } = useQuery<
EventLogsData,
EventLogsVariables
>(GET_EVENT_LOGS, {
@@ -70,6 +70,7 @@ export const useEventLogs = (
hasNextPage,
loading,
error,
refetch,
loadMore,
};
};
@@ -0,0 +1,121 @@
import { msg } from '@lingui/core/macro';
import { type MessageDescriptor } from '@lingui/core';
import { EventLogTable } from '~/generated-metadata/graphql';
export type ColumnConfig = {
id: string;
label: MessageDescriptor;
minWidth: number;
defaultWidth: number;
};
const DEFAULT_COLUMNS: ColumnConfig[] = [
{ id: 'event', label: msg`Event`, minWidth: 100, defaultWidth: 200 },
{
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 150,
},
{ id: 'userId', label: msg`User`, minWidth: 100, defaultWidth: 150 },
{
id: 'properties',
label: msg`Properties`,
minWidth: 200,
defaultWidth: 400,
},
];
const OBJECT_EVENT_COLUMNS: ColumnConfig[] = [
{ id: 'event', label: msg`Event`, minWidth: 100, defaultWidth: 180 },
{
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 130,
},
{ id: 'userId', label: msg`User`, minWidth: 100, defaultWidth: 130 },
{
id: 'recordId',
label: msg`Record ID`,
minWidth: 100,
defaultWidth: 130,
},
{
id: 'objectMetadataId',
label: msg`Object ID`,
minWidth: 100,
defaultWidth: 130,
},
{
id: 'properties',
label: msg`Properties`,
minWidth: 150,
defaultWidth: 300,
},
];
const USAGE_EVENT_COLUMNS: ColumnConfig[] = [
{
id: 'event',
label: msg`Resource Type`,
minWidth: 100,
defaultWidth: 130,
},
{
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 140,
},
{ id: 'userId', label: msg`User`, minWidth: 100, defaultWidth: 130 },
{
id: 'properties',
label: msg`Details`,
minWidth: 200,
defaultWidth: 400,
},
];
const APPLICATION_LOG_COLUMNS: ColumnConfig[] = [
{
id: 'event',
label: msg`Function`,
minWidth: 100,
defaultWidth: 160,
},
{
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 140,
},
{ id: 'level', label: msg`Level`, minWidth: 60, defaultWidth: 80 },
{
id: 'message',
label: msg`Message`,
minWidth: 200,
defaultWidth: 400,
},
{
id: 'executionId',
label: msg`Execution ID`,
minWidth: 100,
defaultWidth: 140,
},
];
const COLUMNS_BY_TABLE: Record<EventLogTable, ColumnConfig[]> = {
[EventLogTable.OBJECT_EVENT]: OBJECT_EVENT_COLUMNS,
[EventLogTable.USAGE_EVENT]: USAGE_EVENT_COLUMNS,
[EventLogTable.APPLICATION_LOG]: APPLICATION_LOG_COLUMNS,
[EventLogTable.WORKSPACE_EVENT]: DEFAULT_COLUMNS,
[EventLogTable.PAGEVIEW]: DEFAULT_COLUMNS,
};
export const getColumnsForEventLogTable = (
table: EventLogTable,
): ColumnConfig[] => {
return COLUMNS_BY_TABLE[table] ?? DEFAULT_COLUMNS;
};
@@ -1,125 +0,0 @@
import { type ReactNode } from 'react';
import { msg } from '@lingui/core/macro';
import { type MessageDescriptor } from '@lingui/core';
import { EventLogJsonCell } from '@/settings/event-logs/components/EventLogJsonCell';
import {
type EventLogRecord,
EventLogTable,
} from '~/generated-metadata/graphql';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
export type ColumnConfig = {
id: string;
label: MessageDescriptor;
minWidth: number;
defaultWidth: number;
renderCell: (record: EventLogRecord) => ReactNode;
};
const EVENT_COLUMN: ColumnConfig = {
id: 'event',
label: msg`Event`,
minWidth: 100,
defaultWidth: 200,
renderCell: (record) => record.event,
};
const TIMESTAMP_COLUMN: ColumnConfig = {
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 150,
renderCell: (record) => beautifyPastDateRelativeToNow(record.timestamp),
};
const USER_COLUMN: ColumnConfig = {
id: 'userId',
label: msg`User`,
minWidth: 100,
defaultWidth: 150,
renderCell: (record) => record.userId ?? '-',
};
const PROPERTIES_COLUMN: ColumnConfig = {
id: 'properties',
label: msg`Properties`,
minWidth: 200,
defaultWidth: 400,
renderCell: (record) => <EventLogJsonCell value={record.properties} />,
};
const DEFAULT_COLUMNS: ColumnConfig[] = [
EVENT_COLUMN,
TIMESTAMP_COLUMN,
USER_COLUMN,
PROPERTIES_COLUMN,
];
const OBJECT_EVENT_COLUMNS: ColumnConfig[] = [
{ ...EVENT_COLUMN, defaultWidth: 180 },
{ ...TIMESTAMP_COLUMN, defaultWidth: 130 },
{ ...USER_COLUMN, defaultWidth: 130 },
{
id: 'recordId',
label: msg`Record ID`,
minWidth: 100,
defaultWidth: 130,
renderCell: (record) => record.recordId ?? '-',
},
{
id: 'objectMetadataId',
label: msg`Object ID`,
minWidth: 100,
defaultWidth: 130,
renderCell: (record) => record.objectMetadataId ?? '-',
},
{ ...PROPERTIES_COLUMN, minWidth: 150, defaultWidth: 300 },
];
const USAGE_EVENT_COLUMNS: ColumnConfig[] = [
{ ...EVENT_COLUMN, label: msg`Resource Type`, defaultWidth: 130 },
{ ...TIMESTAMP_COLUMN, defaultWidth: 140 },
{ ...USER_COLUMN, defaultWidth: 130 },
{ ...PROPERTIES_COLUMN, label: msg`Details` },
];
const APPLICATION_LOG_COLUMNS: ColumnConfig[] = [
{ ...EVENT_COLUMN, label: msg`Function`, defaultWidth: 160 },
{ ...TIMESTAMP_COLUMN, defaultWidth: 140 },
{
id: 'level',
label: msg`Level`,
minWidth: 60,
defaultWidth: 80,
renderCell: (record) => record.properties?.level ?? '-',
},
{
id: 'message',
label: msg`Message`,
minWidth: 200,
defaultWidth: 400,
renderCell: (record) => record.properties?.message ?? '-',
},
{
id: 'executionId',
label: msg`Execution ID`,
minWidth: 100,
defaultWidth: 140,
renderCell: (record) => record.properties?.executionId ?? '-',
},
];
const COLUMNS_BY_TABLE: Record<EventLogTable, ColumnConfig[]> = {
[EventLogTable.OBJECT_EVENT]: OBJECT_EVENT_COLUMNS,
[EventLogTable.USAGE_EVENT]: USAGE_EVENT_COLUMNS,
[EventLogTable.APPLICATION_LOG]: APPLICATION_LOG_COLUMNS,
[EventLogTable.WORKSPACE_EVENT]: DEFAULT_COLUMNS,
[EventLogTable.PAGEVIEW]: DEFAULT_COLUMNS,
};
export const getColumnsForEventLogTable = (
table: EventLogTable,
): ColumnConfig[] => {
return COLUMNS_BY_TABLE[table] ?? DEFAULT_COLUMNS;
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 111 KiB

@@ -0,0 +1,169 @@
import { useContext, useState } from 'react';
import { Select } from '@/ui/input/components/Select';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { H2Title, IconCopy } from 'twenty-ui/display';
import { CodeEditor, IconButton } from 'twenty-ui/input';
import { Card, CardContent, Section } from 'twenty-ui/layout';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledCoverImage = styled.div`
background-position: center;
background-size: cover;
height: 160px;
overflow: hidden;
`;
const StyledConfigButtonsContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${themeCssVariables.spacing[2]};
position: absolute;
right: ${themeCssVariables.spacing[3]};
top: ${themeCssVariables.spacing[3]};
z-index: 1;
`;
const StyledCoverCardContent = styled(CardContent)`
padding: 0;
`;
const StyledCopyButton = styled(IconButton)`
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
`;
const StyledEditorContainer = styled.div`
.monaco-editor,
.monaco-editor .overflow-guard {
background-color: transparent !important;
border: none !important;
}
.monaco-editor .line-hover {
background-color: transparent !important;
}
`;
type McpAuthMethod = 'oauth' | 'api-key';
export const SettingsAiMCP = () => {
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const [authMethod, setAuthMethod] = useState<McpAuthMethod>('oauth');
const { colorScheme } = useContext(ThemeContext);
const coverImage =
colorScheme === 'light'
? '/images/ai/ai-mcp-cover-light.svg'
: '/images/ai/ai-mcp-cover-dark.svg';
const oauthConfig = JSON.stringify(
{
mcpServers: {
twenty: {
type: 'streamable-http',
url: `${REACT_APP_SERVER_BASE_URL}/mcp`,
},
},
},
null,
2,
);
const apiKeyConfig = JSON.stringify(
{
mcpServers: {
twenty: {
type: 'streamable-http',
url: `${REACT_APP_SERVER_BASE_URL}/mcp`,
headers: {
Authorization: 'Bearer [API_KEY]',
},
},
},
},
null,
2,
);
const isOAuth = authMethod === 'oauth';
const activeConfig = isOAuth ? oauthConfig : apiKeyConfig;
const editorHeight = isOAuth ? 170 : 230;
const codeEditorOptions = {
readOnly: true,
domReadOnly: true,
renderLineHighlight: 'none' as const,
renderLineHighlightOnlyWhenFocus: false,
lineNumbers: 'off' as const,
folding: false,
selectionHighlight: false,
occurrencesHighlight: 'off' as const,
scrollBeyondLastLine: false,
hover: {
enabled: false,
},
guides: {
indentation: false,
bracketPairs: false,
bracketPairsHorizontal: false,
},
padding: {
top: 12,
},
};
return (
<Section>
<H2Title
title={t`MCP Server`}
description={t`Access your workspace data from your favorite MCP client like Claude Desktop, Windsurf or Cursor.`}
/>
<Card rounded>
<StyledCoverCardContent divider>
<StyledCoverImage
style={{ backgroundImage: `url('${coverImage}')` }}
/>
</StyledCoverCardContent>
<StyledCoverCardContent>
<StyledEditorContainer style={{ position: 'relative' }}>
<StyledConfigButtonsContainer>
<Select
dropdownId="mcp-auth-method-select"
value={authMethod}
onChange={(value) => setAuthMethod(value as McpAuthMethod)}
options={[
{ label: t`OAuth`, value: 'oauth' },
{ label: t`API Key`, value: 'api-key' },
]}
selectSizeVariant="small"
dropdownWidth={GenericDropdownContentWidth.Medium}
dropdownOffset={{ x: 0, y: 4 }}
/>
<StyledCopyButton
Icon={IconCopy}
onClick={() => {
copyToClipboard(
activeConfig,
t`MCP Configuration copied to clipboard`,
);
}}
size="small"
/>
</StyledConfigButtonsContainer>
<CodeEditor
value={activeConfig}
language="json"
options={codeEditorOptions}
height={editorHeight}
/>
</StyledEditorContainer>
</StyledCoverCardContent>
</Card>
</Section>
);
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 90 KiB

@@ -50,6 +50,10 @@ export const SettingsGeneral = () => {
);
const renderActiveTabContent = () => {
if (activeTabId === GENERAL_TAB_LOGS) {
return <SettingsLogs />;
}
if (activeTabId === GENERAL_TAB_SECURITY) {
return <SettingsSecuritySettings />;
}
@@ -93,13 +97,7 @@ export const SettingsGeneral = () => {
}
links={[{ children: t`Workspace` }, { children: t`General` }]}
>
{activeTabId === GENERAL_TAB_LOGS ? (
<SettingsLogs />
) : (
<SettingsPageContainer>
{renderActiveTabContent()}
</SettingsPageContainer>
)}
<SettingsPageContainer>{renderActiveTabContent()}</SettingsPageContainer>
</SettingsPageLayout>
);
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 94 KiB

@@ -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;
};
+1
View File
@@ -35,6 +35,7 @@ FRONTEND_URL=http://localhost:3001
# AUTH_GOOGLE_APIS_CALLBACK_URL=http://localhost:3000/auth/google-apis/get-access-token
# CODE_INTERPRETER_TYPE=LOCAL
# LOGIC_FUNCTION_TYPE=LOCAL
# LOGIC_FUNCTION_LOGS_ENABLED=true
# STORAGE_TYPE=local
# STORAGE_LOCAL_PATH=.local-storage
# SUPPORT_DRIVER=front
@@ -105,10 +105,6 @@ describe('ClickHouseService', () => {
table: 'test_table',
values: testData,
format: 'JSONEachRow',
clickhouse_settings: {
async_insert: 1,
wait_for_async_insert: 1,
},
});
});
@@ -28,6 +28,10 @@ export class ClickHouseService implements OnModuleInit, OnModuleDestroy {
response: true,
request: true,
},
clickhouse_settings: {
async_insert: 1,
wait_for_async_insert: 1,
},
application: 'twenty',
log: { level: ClickHouseLogLevel.OFF },
});
@@ -84,6 +88,10 @@ export class ClickHouseService implements OnModuleInit, OnModuleDestroy {
response: true,
request: true,
},
clickhouse_settings: {
async_insert: 1,
wait_for_async_insert: 1,
},
application: 'twenty',
log: { level: ClickHouseLogLevel.OFF },
});
@@ -274,10 +282,6 @@ export class ClickHouseService implements OnModuleInit, OnModuleDestroy {
table,
values: chunk,
format: 'JSONEachRow',
clickhouse_settings: {
async_insert: 1,
wait_for_async_insert: 1,
},
});
chunk = [];
currentSizeBytes = 0;
@@ -12,8 +12,3 @@ export const formatDateTimeForClickHouse = (date: Date | string): string => {
export const formatDateForClickHouse = (date: Date): string =>
date.toISOString().slice(0, 10);
// ClickHouse returns DateTime64 values as naive strings (YYYY-MM-DD HH:mm:ss.SSS) in UTC.
// Parse them as UTC explicitly, otherwise `new Date` assumes the server's local timezone.
export const parseClickHouseDateTime = (value: string): Date =>
new Date(`${value.replace(' ', 'T')}Z`);
@@ -1,9 +1,9 @@
import { OBJECT_RECORD_CREATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/object-event/object-record-created';
import { OBJECT_RECORD_DELETED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/object-event/object-record-delete';
import { OBJECT_RECORD_UPDATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/object-event/object-record-updated';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/custom-domain/custom-domain-activated';
import { CUSTOM_DOMAIN_DEACTIVATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/custom-domain/custom-domain-deactivated';
import { type GenericTrackEvent } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/track';
import { OBJECT_RECORD_CREATED_EVENT } from 'src/engine/core-modules/audit/utils/events/object-event/object-record-created';
import { OBJECT_RECORD_DELETED_EVENT } from 'src/engine/core-modules/audit/utils/events/object-event/object-record-delete';
import { OBJECT_RECORD_UPDATED_EVENT } from 'src/engine/core-modules/audit/utils/events/object-event/object-record-updated';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-activated';
import { CUSTOM_DOMAIN_DEACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-deactivated';
import { type GenericTrackEvent } from 'src/engine/core-modules/audit/utils/events/workspace-event/track';
import { formatDateTimeForClickHouse } from 'src/database/clickHouse/clickHouse.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { USER_WORKSPACE_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util';
@@ -56,19 +56,14 @@ export const typeORMCoreModuleOptions: TypeOrmModuleOptions = {
migrationsRun: false,
migrationsTableName: '_typeorm_migrations',
metadataTableName: '_typeorm_generated_columns_and_materialized_views',
// The TypeORM migration system is frozen — historical migrations live in
// `legacy-typeorm-migrations-do-not-add/` and are loaded here only so the
// `_typeorm_migrations` table stays consistent for older deployments.
// Do NOT add new files there: write a fast/slow instance command instead.
// See `packages/twenty-server/docs/UPGRADE_COMMANDS.md`.
migrations:
process.env.IS_BILLING_ENABLED === 'true'
? [
`${isJest ? 'src/' : 'dist/'}database/typeorm/core/legacy-typeorm-migrations-do-not-add/common/*{.ts,.js}`,
`${isJest ? 'src/' : 'dist/'}database/typeorm/core/legacy-typeorm-migrations-do-not-add/billing/*{.ts,.js}`,
`${isJest ? 'src/' : 'dist/'}database/typeorm/core/migrations/common/*{.ts,.js}`,
`${isJest ? 'src/' : 'dist/'}database/typeorm/core/migrations/billing/*{.ts,.js}`,
]
: [
`${isJest ? 'src/' : 'dist/'}database/typeorm/core/legacy-typeorm-migrations-do-not-add/common/*{.ts,.js}`,
`${isJest ? 'src/' : 'dist/'}database/typeorm/core/migrations/common/*{.ts,.js}`,
],
ssl:
process.env.PG_SSL_ALLOW_SELF_SIGNED === 'true'
@@ -1,23 +0,0 @@
# Legacy TypeORM migrations — do not add new files here
This directory contains historical TypeORM migrations (`common/` and `billing/`).
They are kept so that older deployments can still replay them against the
`_typeorm_migrations` table.
**The TypeORM migration system is frozen.** Do not add new files here.
The active upgrade system is **instance commands** (fast / slow) and
**workspace commands**, registered with `@RegisteredInstanceCommand` and
`@RegisteredWorkspaceCommand`. Generate one with:
```bash
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>
```
See `packages/twenty-server/docs/UPGRADE_COMMANDS.md` for the full guide and
`packages/twenty-server/src/database/commands/upgrade-version-command/` for
existing examples.
Note: `../migrations/utils/` is **not** legacy — those SQL helpers are still
imported by current instance/workspace commands and live outside this folder
on purpose.

Some files were not shown because too many files have changed in this diff Show More