feat(forks): opt-in fork_triggers flag clones triggers/schedules disabled

Adds `workspace.fork_triggers` (default false) and a matching field on
CreateWorkspaceFork. When the user opts in, fork creation also runs
clone_triggers_and_schedules: every row in schedule and the ten
*_trigger tables is copied to the fork with mode='disabled' /
enabled=false. Listener identifiers (group_id, replication_slot_name,
subscription_name, …) are copied verbatim — the runtime suffix that
prevents the fork from competing with the parent ships in a follow-up
PR.

native_trigger is intentionally skipped: those triggers manage external
webhook state we don't want duplicated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-04-29 18:40:31 +02:00
co-authored by Claude Opus 4.7
parent a4d8f138ae
commit 482b7c1524
4 changed files with 265 additions and 2 deletions
@@ -0,0 +1 @@
ALTER TABLE workspace DROP COLUMN fork_triggers;
@@ -0,0 +1,4 @@
-- Tracks whether triggers and schedules were cloned (always disabled) at fork
-- creation time. Read by the create_fork handler; reflected in the UI so users
-- can tell which forks carry the parent's trigger/schedule definitions.
ALTER TABLE workspace ADD COLUMN fork_triggers BOOLEAN NOT NULL DEFAULT FALSE;
@@ -401,6 +401,12 @@ struct CreateWorkspaceFork {
/// forked workspace's datatable config to point to the new database.
#[serde(default)]
forked_datatables: Vec<ForkedDatatableInfo>,
/// When true, clone every trigger and schedule from the parent workspace
/// (always with mode='disabled'/enabled=false). The user re-enables what
/// they need; warnings on enable cover the parent-conflict case. When
/// false (default), the fork starts trigger-empty — same as today.
#[serde(default)]
fork_triggers: bool,
}
#[derive(Deserialize)]
@@ -3790,6 +3796,243 @@ async fn clone_workspace_data(
Ok(())
}
/// Clone every trigger and schedule from the parent workspace, forcing
/// `mode='disabled'` / `enabled=false`. Called only when the fork-creation
/// request opts in via `fork_triggers=true`. Listener identifiers
/// (group_id, replication_slot_name, subscription_name, …) are copied
/// verbatim — the runtime suffix that prevents the fork from competing with
/// the parent ships in a follow-up PR (see Phase 3).
async fn clone_triggers_and_schedules(
tx: &mut Transaction<'_, Postgres>,
source_workspace_id: &str,
target_workspace_id: &str,
) -> Result<()> {
sqlx::query!(
r#"INSERT INTO schedule (
workspace_id, path, edited_by, edited_at, schedule, enabled, script_path,
args, extra_perms, is_flow, email, error, timezone, on_failure,
on_recovery, on_failure_times, on_failure_exact, on_failure_extra_args,
on_recovery_times, on_recovery_extra_args, ws_error_handler_muted, retry,
summary, no_flow_overlap, tag, paused_until, on_success, on_success_extra_args,
cron_version, description, dynamic_skip, permissioned_as, labels
)
SELECT
$1, path, edited_by, edited_at, schedule, FALSE, script_path,
args, extra_perms, is_flow, email, error, timezone, on_failure,
on_recovery, on_failure_times, on_failure_exact, on_failure_extra_args,
on_recovery_times, on_recovery_extra_args, ws_error_handler_muted, retry,
summary, no_flow_overlap, tag, paused_until, on_success, on_success_extra_args,
cron_version, description, dynamic_skip, permissioned_as, labels
FROM schedule WHERE workspace_id = $2"#,
target_workspace_id,
source_workspace_id,
)
.execute(&mut **tx)
.await?;
sqlx::query!(
r#"INSERT INTO http_trigger (
path, route_path, route_path_key, script_path, is_flow, workspace_id,
edited_by, edited_at, extra_perms, authentication_method, http_method,
static_asset_config, is_static_website, workspaced_route, wrap_body,
raw_string, authentication_resource_path, summary, description,
error_handler_path, error_handler_args, retry, request_type, mode,
permissioned_as, labels
)
SELECT
path, route_path, route_path_key, script_path, is_flow, $1,
edited_by, edited_at, extra_perms, authentication_method, http_method,
static_asset_config, is_static_website, workspaced_route, wrap_body,
raw_string, authentication_resource_path, summary, description,
error_handler_path, error_handler_args, retry, request_type, 'disabled'::TRIGGER_MODE,
permissioned_as, labels
FROM http_trigger WHERE workspace_id = $2"#,
target_workspace_id,
source_workspace_id,
)
.execute(&mut **tx)
.await?;
sqlx::query!(
r#"INSERT INTO websocket_trigger (
path, url, script_path, is_flow, workspace_id, edited_by, edited_at,
extra_perms, server_id, last_server_ping, error, filters, initial_messages,
url_runnable_args, can_return_message, error_handler_path, error_handler_args,
retry, can_return_error_result, mode, permissioned_as, filter_logic, labels,
heartbeat
)
SELECT
path, url, script_path, is_flow, $1, edited_by, edited_at,
extra_perms, NULL, NULL, NULL, filters, initial_messages,
url_runnable_args, can_return_message, error_handler_path, error_handler_args,
retry, can_return_error_result, 'disabled'::TRIGGER_MODE, permissioned_as, filter_logic, labels,
heartbeat
FROM websocket_trigger WHERE workspace_id = $2"#,
target_workspace_id,
source_workspace_id,
)
.execute(&mut **tx)
.await?;
sqlx::query!(
r#"INSERT INTO kafka_trigger (
path, kafka_resource_path, topics, group_id, script_path, is_flow,
workspace_id, edited_by, edited_at, extra_perms, server_id,
last_server_ping, error, error_handler_path, error_handler_args, retry,
mode, filters, auto_offset_reset, reset_offset, auto_commit,
permissioned_as, filter_logic, labels
)
SELECT
path, kafka_resource_path, topics, group_id, script_path, is_flow,
$1, edited_by, edited_at, extra_perms, NULL,
NULL, NULL, error_handler_path, error_handler_args, retry,
'disabled'::TRIGGER_MODE, filters, auto_offset_reset, reset_offset, auto_commit,
permissioned_as, filter_logic, labels
FROM kafka_trigger WHERE workspace_id = $2"#,
target_workspace_id,
source_workspace_id,
)
.execute(&mut **tx)
.await?;
sqlx::query!(
r#"INSERT INTO nats_trigger (
path, nats_resource_path, subjects, stream_name, consumer_name,
use_jetstream, script_path, is_flow, workspace_id, edited_by, edited_at,
extra_perms, server_id, last_server_ping, error, error_handler_path,
error_handler_args, retry, mode, permissioned_as, labels
)
SELECT
path, nats_resource_path, subjects, stream_name, consumer_name,
use_jetstream, script_path, is_flow, $1, edited_by, edited_at,
extra_perms, NULL, NULL, NULL, error_handler_path,
error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels
FROM nats_trigger WHERE workspace_id = $2"#,
target_workspace_id,
source_workspace_id,
)
.execute(&mut **tx)
.await?;
sqlx::query!(
r#"INSERT INTO postgres_trigger (
path, script_path, is_flow, workspace_id, edited_by, edited_at,
extra_perms, postgres_resource_path, error, server_id, last_server_ping,
replication_slot_name, publication_name, error_handler_path,
error_handler_args, retry, mode, permissioned_as, labels
)
SELECT
path, script_path, is_flow, $1, edited_by, edited_at,
extra_perms, postgres_resource_path, NULL, NULL, NULL,
replication_slot_name, publication_name, error_handler_path,
error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels
FROM postgres_trigger WHERE workspace_id = $2"#,
target_workspace_id,
source_workspace_id,
)
.execute(&mut **tx)
.await?;
sqlx::query!(
r#"INSERT INTO mqtt_trigger (
mqtt_resource_path, subscribe_topics, client_version, v5_config, v3_config,
client_id, path, script_path, is_flow, workspace_id, edited_by, edited_at,
extra_perms, server_id, last_server_ping, error, error_handler_path,
error_handler_args, retry, mode, permissioned_as, labels
)
SELECT
mqtt_resource_path, subscribe_topics, client_version, v5_config, v3_config,
client_id, path, script_path, is_flow, $1, edited_by, edited_at,
extra_perms, NULL, NULL, NULL, error_handler_path,
error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels
FROM mqtt_trigger WHERE workspace_id = $2"#,
target_workspace_id,
source_workspace_id,
)
.execute(&mut **tx)
.await?;
sqlx::query!(
r#"INSERT INTO sqs_trigger (
path, queue_url, aws_resource_path, message_attributes, script_path,
is_flow, workspace_id, edited_by, edited_at, extra_perms, error,
server_id, last_server_ping, aws_auth_resource_type, error_handler_path,
error_handler_args, retry, mode, permissioned_as, labels
)
SELECT
path, queue_url, aws_resource_path, message_attributes, script_path,
is_flow, $1, edited_by, edited_at, extra_perms, NULL,
NULL, NULL, aws_auth_resource_type, error_handler_path,
error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels
FROM sqs_trigger WHERE workspace_id = $2"#,
target_workspace_id,
source_workspace_id,
)
.execute(&mut **tx)
.await?;
sqlx::query!(
r#"INSERT INTO gcp_trigger (
gcp_resource_path, topic_id, subscription_id, delivery_type,
delivery_config, path, script_path, is_flow, workspace_id, edited_by,
edited_at, extra_perms, server_id, last_server_ping, error,
subscription_mode, error_handler_path, error_handler_args, retry,
auto_acknowledge_msg, ack_deadline, mode, permissioned_as, labels
)
SELECT
gcp_resource_path, topic_id, subscription_id, delivery_type,
delivery_config, path, script_path, is_flow, $1, edited_by,
edited_at, extra_perms, NULL, NULL, NULL,
subscription_mode, error_handler_path, error_handler_args, retry,
auto_acknowledge_msg, ack_deadline, 'disabled'::TRIGGER_MODE, permissioned_as, labels
FROM gcp_trigger WHERE workspace_id = $2"#,
target_workspace_id,
source_workspace_id,
)
.execute(&mut **tx)
.await?;
sqlx::query!(
r#"INSERT INTO azure_trigger (
azure_resource_path, azure_mode, scope_resource_id, topic_name,
subscription_name, event_type_filters, push_auth_config, path, script_path,
is_flow, workspace_id, edited_by, email, edited_at, extra_perms, server_id,
last_server_ping, error, mode, permissioned_as, error_handler_path,
error_handler_args, retry, labels
)
SELECT
azure_resource_path, azure_mode, scope_resource_id, topic_name,
subscription_name, event_type_filters, push_auth_config, path, script_path,
is_flow, $1, edited_by, email, edited_at, extra_perms, NULL,
NULL, NULL, 'disabled'::TRIGGER_MODE, permissioned_as, error_handler_path,
error_handler_args, retry, labels
FROM azure_trigger WHERE workspace_id = $2"#,
target_workspace_id,
source_workspace_id,
)
.execute(&mut **tx)
.await?;
sqlx::query!(
r#"INSERT INTO email_trigger (
path, local_part, workspaced_local_part, script_path, is_flow,
workspace_id, edited_by, edited_at, extra_perms, error_handler_path,
error_handler_args, retry, mode, permissioned_as, labels
)
SELECT
path, local_part, workspaced_local_part, script_path, is_flow,
$1, edited_by, edited_at, extra_perms, error_handler_path,
error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels
FROM email_trigger WHERE workspace_id = $2"#,
target_workspace_id,
source_workspace_id,
)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn update_workspace_settings(
tx: &mut Transaction<'_, Postgres>,
source_workspace_id: &str,
@@ -4621,12 +4864,13 @@ async fn create_workspace_fork(
sqlx::query!(
"INSERT INTO workspace
(id, name, owner, parent_workspace_id)
VALUES ($1, $2, $3, $4)",
(id, name, owner, parent_workspace_id, fork_triggers)
VALUES ($1, $2, $3, $4, $5)",
forked_id,
nw.name,
authed.email,
parent_workspace_id,
nw.fork_triggers,
)
.execute(&mut *tx)
.await?;
@@ -4657,6 +4901,13 @@ async fn create_workspace_fork(
// Clone all data from the parent workspace using Rust implementation
clone_workspace_data(&mut tx, &parent_workspace_id, &forked_id).await?;
// Optionally clone triggers and schedules. Always with mode='disabled' /
// enabled=false — the user re-enables in the fork, with warnings if the
// parent has the same path enabled (see Phase 4).
if nw.fork_triggers {
clone_triggers_and_schedules(&mut tx, &parent_workspace_id, &forked_id).await?;
}
// Update forked datatable settings to point to new databases
for fdt in &nw.forked_datatables {
apply_forked_datatable(&db, &mut tx, &parent_workspace_id, &forked_id, fdt).await?;
+7
View File
@@ -25836,6 +25836,13 @@ components:
new_dbname:
type: string
description: "New database name for the fork"
fork_triggers:
type: boolean
description: >
When true, every trigger and schedule from the parent workspace is
cloned into the fork with mode='disabled' / enabled=false. The user
re-enables in the fork. When false (default), the fork starts
trigger-empty.
required:
- id
- name