Files
Ruben Fiszel f365929eaa feat(fork): merge a fork deletion on evidence, not on the counters (#10484)
* feat(fork): merge a fork deletion on evidence, not on the counters

`workspace_diff.ahead`/`.behind` record that a write happened on a side,
not what it was or who made it. That leaves one row shape undecidable: an
item the parent has and the fork does not can mean the parent added it,
the fork deleted it, or a git-sync pull reverted a deploy that had just
brought it in. #10467 kept every such row out of the merge direction,
which killed the phantom but also dropped the only way to propagate a
fork-side deletion and left a rename's old path behind in the parent.

Record the evidence instead:

- `workspace_diff` gains, per side, the last event's kind (`write` /
  `delete` / `rename_from`) and origin (`authored` / `sync`). Rows
  written before the migration have neither and keep #10467's behavior.
- The kind is probed from whether the path still holds an item once the
  write has committed; an item kind the probe doesn't map records no
  evidence rather than a deletion. Create and update are not split —
  nothing at that point tells them apart for every kind, and the
  comparison already recomputes existence per side.
- The origin comes from an `X-Windmill-Deploy-Origin` header the API
  scopes into a task-local for the request. It is the load-bearing half:
  recording `delete` alone would read a git-sync revert as a fork
  deletion and reproduce the original bug. Two clients set it — `wmill
  sync push` (which the git-sync auto-pull runs inside a job) and the
  compare page's parent→fork "Update fork". Merging the other way stays
  authored so a deletion keeps propagating up a fork chain.
- The merge direction admits a parent-only row only when the fork's last
  event was an authored delete or rename-away. Such a row stays opt-in,
  never bulk-selected, and reads "Removes in <parent>"; the update
  direction keeps offering it back as "New".

A fork deletion and a rename now merge into the parent, a rename leaves
no duplicate behind, and a fork the parent also edited surfaces in both
directions instead of the parent silently winning.

Fixes WIN-2289

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(fork): address review — detached tallies, enum wire values, doc duplication

Codex P1: a dependency job tallies its deploy whenever it happens to finish,
and the event kind is probed from the state at that moment. If anything
removed the path in between (a git-sync revert), the stale tally read that
deletion as its own and filed it as authored — handing the merge exactly the
removal this is meant to withhold. `tally_deployed_object_changes` now takes
`Option<DeployOrigin>`; `None` bumps the counter and leaves the evidence
columns as the last vouching tally left them, and the worker path passes it.
Covered by extending the removal-origin test: a detached tally after the sync
archive must not disturb `(delete, sync)`.

Also from review:
- `fork_removed_it` compares through `DeployOrigin::as_str()` /
  `DeployEventKind::as_str()` rather than repeating their wire values, so a
  renamed variant can't silently make the predicate always false.
- `deploy_origin`'s module doc no longer claims `sync` is inert: it cannot
  make the merge propose a removal, but it does drop a row out of both sides
  of the `all_ahead_items_visible` comparison.
- `WorkspaceDiffRow` says why only the fork half of the evidence is consumed.
- The delete-vs-revert rationale is stated once (the migration) instead of
  restated in eight files.
- `PATH_KEYED_TABLES` is swept by a test: its query is built at runtime, so a
  wrong table name is not a compile error and would only surface as a failed
  tally for that trigger kind in a fork.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(fork): let only a request task vouch for a deploy event

Round 2 found the first fix incomplete. Detaching only the failed/cancelled
dependency path left the common route untouched: a dependency job that
succeeds calls `handle_deployment_metadata` from the worker, where
`deploy_origin::current()` read as `Authored`. A sync archiving the script
while its lock generation was pending then had its deletion probed on
completion and refiled as authored — the same fabricated removal, on the
path most deploys actually take.

`current()` now returns `Option`, `Some` only inside the request scope the
API always enters. Having no scope means "not the task that served this
write", which is true of every worker-side call and needs no marking at the
call site. The integration test drives the real `handle_deployment_metadata`
off a request task instead of the tally directly, and fails without this.

Two more from the same round:

- The script dependency handler passed no `renamed_from`, unlike the flow
  and app handlers next to it. A lock-generating create has no earlier
  tally, so that was the only chance for the path a rename vacated to be
  recorded at all — renames of Python/TS scripts left the old path in the
  parent, which the bash-only manual check missed.
- The tally now drops a `renamed_from` equal to the path itself. Callers
  pass the previous path whether or not the deploy moved the item, so an
  unfiltered one both counted the path twice and stamped it `rename_from`
  when nothing was renamed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(fork): carry a deploy's origin into the dependency job it queues

Round 3 caught the previous fix cutting too deep. Refusing a detached tally
any claim also refused its rename evidence, and a lock-generating deploy has
no other tally — so the `renamed_from` added alongside it was inert, and a
renamed flow, app or Python script still left its old path in the parent
with nothing to merge. Flows and apps always generate, so renames worked
essentially nowhere.

The two capabilities are now separate. `TallyEvidence` says whether the
tallying task served the write (`Served`, may probe what the path holds now)
or is reporting one that committed earlier (`Deferred`, may not), and each
column is written only from a source that answers for it. The origin itself
is a fact of the deploy either way, so the request stamps it into the
dependency job's args and the worker re-enters the scope with it — the last
place that knows it handing it to the only tally that will run.

Also from round 3: `WorkspaceDiffRow`'s event fields skip serializing `None`
rather than emitting `null`, matching what the schema declares (OpenAPI
3.0.3 ignores a `description` sibling of `$ref`, so those moved onto the
shared schemas).

Verified against a live worker: renaming a flow in a fork records
`(rename_from, authored)` on the vacated path and the merge offers its
removal, while the deployed path claims nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(fork): mark the CLI's parent-to-fork merge as sync

`wmill workspace merge --direction to-fork` is the CLI's "Update fork" and
deletes items in the fork, but without the marker the compare page sets. Its
deletions were recorded as authored fork decisions, so once the parent
recreated such a path the merge would offer deleting it there.

Also from review: an unrecognized deploy-origin arg now reads as no evidence
rather than as authored — strict where a request header is lenient, since an
unmarked request really is authored but an unreadable stored value is skew.
Reading the arg moved next to `stamp_origin_arg`, the half that writes it, so
the round trip a lock-generating deploy depends on is covered by one test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: drop the imports the shared arg reader made unused

CI compiles with `-D warnings`, so this was four red Backend jobs rather
than a lint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(fork): stop a stale deferred rename from restating a removed path

Nothing orders these events. A tally that served the write made its claim
inside its own commit, but a deferred one reports a write that landed at an
unknown remove. So a lock-generating rename whose dependency job finished
after a sync had removed the vacated path could overwrite `(delete, sync)`
with `(rename_from, authored)` — the path is gone either way, so the merge
would then offer removing it from the parent on the strength of the older
event.

A deferred claim now only writes where the side has none, which is the case
it exists for: a vacated path that nothing else has spoken for. The
regression asserts the ordering directly, and fails without the guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(fork): record a rename's vacated path from the request that made it

The deferred mechanism could not be made correct, as round 7 showed: its
guard protected an existing row, but that row is deleted as soon as the two
workspaces agree on the path — so a rename job finishing after the
reconciliation inserted fresh, and the stale claim reappeared against
whatever the parent later recreated there. Ordering cannot be recovered
outside the row, because the row is disposable.

So the vacated path is now recorded by the request, which is inside its own
commit and whose row shares the counter's lifetime. A deploy that hands its
metadata to a dependency job — every flow and app, and any script needing a
lock — calls `tally_rename_vacated_path` once its transaction has committed;
scripts reach it through the post-commit hook they already had, which grew a
second variant rather than new plumbing.

That lets the whole deferred apparatus go: `TallyEvidence`, the origin job
arg and its round trip. `deploy_origin::current` is `Some` only inside a
request scope again, and `handle_deployment_metadata` hands `renamed_from`
to the tally only when it can answer for it — git-sync still gets it either
way, so the rename keeps naming itself in the commit message.

The vacated path's kind now reads `delete` rather than `rename_from` for
these deploys, since it is probed rather than declared. The merge treats the
two alike; only the row's tooltip is less specific.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(fork): cover raw-app renames, and stop firing CI before the lock exists

Two things the vacated-path call broke or missed:

- `create_script` reads its third return value as "no lock generation
  needed" to decide whether the script is runnable now, and the new
  `VacatedPath` variant made that true for renames that do generate. Those
  fired dependent CI tests from the API against a version with no lockfile,
  and again from the dependency job. The variant now decides it explicitly.
- Raw apps rename through `update_app_raw`, a separate route into
  `update_app_internal`, which the new call had not been attached to. Both
  routes now go through one helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(fork): assert the kind only an inline rename can record

`rename_from` is what a deploy says when it knows it moved the item, which
only the path that reports both halves from its own request can. Nothing
pinned it, and that is the side the vacated-path change touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to a45bec03922d305aad5893ed354dc029c7f97bb4

This commit updates the EE repository reference after PR #709 was merged in windmill-ee-private.

Previous ee-repo-ref: 62f494b2a51de0dfc0cfa0c3530ff19a1d32667c

New ee-repo-ref: a45bec03922d305aad5893ed354dc029c7f97bb4

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-08-04 00:50:27 +02:00

698 lines
24 KiB
Rust

/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use windmill_common::{scripts::ScriptHash, DB};
#[cfg(feature = "private")]
pub mod git_sync_ee;
pub mod git_sync_oss;
#[cfg(feature = "private")]
pub use git_sync_ee::{
enqueue_git_pull_dry_run, enqueue_git_pull_job, handle_deployment_metadata,
handle_deployment_metadata_batch, handle_fork_branch_creation, persist_auto_pull_state,
reconcile_and_enqueue_pull, reconcile_fork_branch_pull, record_auto_pull_failure,
tally_deployed_object_changes,
};
#[cfg(not(feature = "private"))]
pub use git_sync_oss::{
handle_deployment_metadata, handle_deployment_metadata_batch, handle_fork_branch_creation,
tally_deployed_object_changes,
};
#[derive(Clone, Debug)]
pub enum DeployedObject {
Script {
hash: ScriptHash,
path: String,
parent_path: Option<String>,
},
Flow {
path: String,
parent_path: Option<String>,
version: i64,
},
App {
path: String,
version: i64,
parent_path: Option<String>,
},
RawApp {
path: String,
version: i64,
parent_path: Option<String>,
},
Folder {
path: String,
},
Resource {
path: String,
parent_path: Option<String>,
},
Variable {
path: String,
parent_path: Option<String>,
},
Schedule {
path: String,
},
ResourceType {
path: String,
},
User {
email: String,
},
Group {
name: String,
},
HttpTrigger {
path: String,
parent_path: Option<String>,
},
WebsocketTrigger {
path: String,
parent_path: Option<String>,
},
KafkaTrigger {
path: String,
parent_path: Option<String>,
},
NatsTrigger {
path: String,
parent_path: Option<String>,
},
PostgresTrigger {
path: String,
parent_path: Option<String>,
},
MqttTrigger {
path: String,
parent_path: Option<String>,
},
AmqpTrigger {
path: String,
parent_path: Option<String>,
},
SqsTrigger {
path: String,
parent_path: Option<String>,
},
GcpTrigger {
path: String,
parent_path: Option<String>,
},
AzureTrigger {
path: String,
parent_path: Option<String>,
},
EmailTrigger {
path: String,
parent_path: Option<String>,
},
Settings {
setting_type: String,
},
Key {
key_type: String,
},
WorkspaceDependencies {
path: String,
},
/// A single data table migration, identified by `<datatable>/<timestamp>_<name>`.
DatatableMigration {
path: String,
},
}
impl DeployedObject {
pub fn get_path(&self) -> String {
match self {
DeployedObject::Script { path, .. } => path.to_owned(),
DeployedObject::Flow { path, .. } => path.to_owned(),
DeployedObject::App { path, .. } => path.to_owned(),
DeployedObject::RawApp { path, .. } => path.to_owned(),
DeployedObject::Folder { path, .. } => path.to_owned(),
DeployedObject::Resource { path, .. } => path.to_owned(),
DeployedObject::Variable { path, .. } => path.to_owned(),
DeployedObject::Schedule { path, .. } => path.to_owned(),
DeployedObject::ResourceType { path, .. } => path.to_owned(),
DeployedObject::User { email } => format!("users/{email}"),
DeployedObject::Group { name } => format!("groups/{name}"),
DeployedObject::HttpTrigger { path, .. } => path.to_owned(),
DeployedObject::WebsocketTrigger { path, .. } => path.to_owned(),
DeployedObject::KafkaTrigger { path, .. } => path.to_owned(),
DeployedObject::NatsTrigger { path, .. } => path.to_owned(),
DeployedObject::PostgresTrigger { path, .. } => path.to_owned(),
DeployedObject::MqttTrigger { path, .. } => path.to_owned(),
DeployedObject::AmqpTrigger { path, .. } => path.to_owned(),
DeployedObject::SqsTrigger { path, .. } => path.to_owned(),
DeployedObject::GcpTrigger { path, .. } => path.to_owned(),
DeployedObject::AzureTrigger { path, .. } => path.to_owned(),
DeployedObject::EmailTrigger { path, .. } => path.to_owned(),
DeployedObject::Settings { .. } => "settings.yaml".to_string(),
DeployedObject::Key { .. } => "encryption_key.yaml".to_string(),
DeployedObject::WorkspaceDependencies { path, .. } => path.to_owned(),
DeployedObject::DatatableMigration { path } => path.to_owned(),
}
}
/// The repo-relative path git sync syncs on: what the sync item carries, and
/// therefore what the CLI turns into `--extra-includes` globs and `git add`
/// pathspecs.
///
/// It only differs from [`Self::get_path`] for data table migrations, whose
/// object path (`<datatable>/<version>_<name>`, the `workspace_diff` key) is
/// relative to the `migrations/datatable/` prefix the workspace export writes
/// them under. Without the prefix the CLI derives globs that match no file,
/// so nothing gets staged and the push silently commits nothing.
pub fn get_git_sync_path(&self) -> String {
match self {
DeployedObject::DatatableMigration { path } => {
format!("migrations/datatable/{path}")
}
_ => self.get_path(),
}
}
/// Whether the object skips the repo's include/exclude path filters. True for
/// every kind that lives outside the `f/`/`u/` path namespaces those filters
/// are written against — the object-type filter is what governs them instead.
pub fn get_ignore_regex_filter(&self) -> bool {
match self {
Self::User { .. }
| Self::Group { .. }
| Self::ResourceType { .. }
| Self::Settings { .. }
| Self::Key { .. }
| Self::WorkspaceDependencies { .. }
| Self::DatatableMigration { .. } => true,
_ => false,
}
}
pub fn get_parent_path(&self) -> Option<String> {
match self {
DeployedObject::Script { parent_path, .. } => parent_path.to_owned(),
DeployedObject::Flow { parent_path, .. } => parent_path.to_owned(),
DeployedObject::App { parent_path, .. } => parent_path.to_owned(),
DeployedObject::RawApp { parent_path, .. } => parent_path.to_owned(),
DeployedObject::Folder { .. } => None,
DeployedObject::Resource { parent_path, .. } => parent_path.to_owned(),
DeployedObject::Variable { parent_path, .. } => parent_path.to_owned(),
DeployedObject::Schedule { .. } => None,
DeployedObject::ResourceType { .. } => None,
DeployedObject::User { .. } => None,
DeployedObject::Group { .. } => None,
DeployedObject::HttpTrigger { parent_path, .. } => parent_path.to_owned(),
DeployedObject::WebsocketTrigger { parent_path, .. } => parent_path.to_owned(),
DeployedObject::KafkaTrigger { parent_path, .. } => parent_path.to_owned(),
DeployedObject::NatsTrigger { parent_path, .. } => parent_path.to_owned(),
DeployedObject::PostgresTrigger { parent_path, .. } => parent_path.to_owned(),
DeployedObject::MqttTrigger { parent_path, .. } => parent_path.to_owned(),
DeployedObject::AmqpTrigger { parent_path, .. } => parent_path.to_owned(),
DeployedObject::SqsTrigger { parent_path, .. } => parent_path.to_owned(),
DeployedObject::GcpTrigger { parent_path, .. } => parent_path.to_owned(),
DeployedObject::AzureTrigger { parent_path, .. } => parent_path.to_owned(),
DeployedObject::EmailTrigger { parent_path, .. } => parent_path.to_owned(),
DeployedObject::Settings { .. } => None,
DeployedObject::Key { .. } => None,
DeployedObject::WorkspaceDependencies { .. } => None,
DeployedObject::DatatableMigration { .. } => None,
}
}
pub fn get_kind(&self) -> String {
match self {
DeployedObject::Script { .. } => "script",
DeployedObject::Flow { .. } => "flow",
DeployedObject::App { .. } => "app",
DeployedObject::RawApp { .. } => "raw_app",
DeployedObject::Folder { .. } => "folder",
DeployedObject::Resource { .. } => "resource",
DeployedObject::Variable { .. } => "variable",
DeployedObject::Schedule { .. } => "schedule",
DeployedObject::ResourceType { .. } => "resource_type",
DeployedObject::User { .. } => "user",
DeployedObject::Group { .. } => "group",
DeployedObject::HttpTrigger { .. } => "http_trigger",
DeployedObject::WebsocketTrigger { .. } => "websocket_trigger",
DeployedObject::KafkaTrigger { .. } => "kafka_trigger",
DeployedObject::NatsTrigger { .. } => "nats_trigger",
DeployedObject::PostgresTrigger { .. } => "postgres_trigger",
DeployedObject::MqttTrigger { .. } => "mqtt_trigger",
DeployedObject::AmqpTrigger { .. } => "amqp_trigger",
DeployedObject::SqsTrigger { .. } => "sqs_trigger",
DeployedObject::GcpTrigger { .. } => "gcp_trigger",
DeployedObject::AzureTrigger { .. } => "azure_trigger",
DeployedObject::EmailTrigger { .. } => "email_trigger",
DeployedObject::Settings { .. } => "settings",
DeployedObject::Key { .. } => "key",
DeployedObject::WorkspaceDependencies { .. } => "workspace_dependencies",
DeployedObject::DatatableMigration { .. } => "datatable_migration",
}
.to_string()
}
}
/// Record, from the request that made it, that a rename left `vacated` empty.
/// Only its path and kind are read, so build it naming the path the rename left.
///
/// A deploy whose metadata is handled by its dependency job needs this: that job
/// runs at an unknown remove from the write, and the row it would write is
/// deleted as soon as the two workspaces agree on the path — so a claim it made
/// could reappear later against an item the parent has since recreated. Call
/// once the rename has committed; the tally reads what the path holds now.
pub async fn tally_rename_vacated_path(
db: &DB,
w_id: &str,
vacated: DeployedObject,
) -> windmill_common::error::Result<()> {
tally_deployed_object_changes(
w_id,
&vacated,
db,
None,
windmill_common::deploy_origin::current(),
)
.await
}
/// Item kinds whose `workspace_diff` path is the `path` column of a table named
/// after the kind. Interpolated into SQL, so it must stay a hardcoded allowlist —
/// and, unlike the `sqlx::query!` arms below, a wrong name here is not a compile
/// error, so `workspace_comparison.rs` sweeps this list against a live database.
pub const PATH_KEYED_TABLES: &[&str] = &[
"resource",
"variable",
"schedule",
"http_trigger",
"websocket_trigger",
"kafka_trigger",
"nats_trigger",
"postgres_trigger",
"mqtt_trigger",
"amqp_trigger",
"sqs_trigger",
"gcp_trigger",
"azure_trigger",
"email_trigger",
];
/// What the deploy event that just committed did to `path`: [`DeployEventKind::Write`]
/// if the workspace still holds an item there, [`DeployEventKind::Delete`] if it does
/// not. `None` for a kind this does not know how to probe, so an unmapped kind is
/// recorded as no evidence rather than as a deletion.
///
/// Existence mirrors the comparison's (`compare_two_*`): an archived script or flow
/// counts as absent. Runs on `&db` (no RLS) — the caller is a post-commit tally, not
/// a user-facing read.
pub async fn probe_deploy_event_kind(
db: &DB,
w_id: &str,
kind: &str,
path: &str,
) -> windmill_common::error::Result<Option<windmill_common::deploy_origin::DeployEventKind>> {
use windmill_common::deploy_origin::DeployEventKind;
let exists: Option<bool> = match kind {
"script" => Some(
sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2 AND archived = false)",
w_id,
path
)
.fetch_one(db)
.await?
.unwrap_or(false),
),
"flow" => Some(
sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2 AND archived = false)",
w_id,
path
)
.fetch_one(db)
.await?
.unwrap_or(false),
),
// Raw apps live in the `app` table too, keyed by path like a regular app.
"app" | "raw_app" => Some(
sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM app WHERE workspace_id = $1 AND path = $2)",
w_id,
path
)
.fetch_one(db)
.await?
.unwrap_or(false),
),
"folder" => {
let name = path.strip_prefix("f/").unwrap_or(path);
Some(
sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM folder WHERE workspace_id = $1 AND name = $2)",
w_id,
name
)
.fetch_one(db)
.await?
.unwrap_or(false),
)
}
"resource_type" => Some(
sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = $1 AND name = $2)",
w_id,
path
)
.fetch_one(db)
.await?
.unwrap_or(false),
),
// Identified by (datatable, timestamp), which survives a rename of the
// migration's `name` segment; the full path does not.
"datatable_migration" => match path
.split_once('/')
.and_then(|(dt, rest)| Some((dt, rest.split_once('_')?.0.parse::<i64>().ok()?)))
{
Some((datatable, timestamp)) => Some(
sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM datatable_migrations \
WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3)",
w_id,
datatable,
timestamp
)
.fetch_one(db)
.await?
.unwrap_or(false),
),
None => None,
},
k if PATH_KEYED_TABLES.contains(&k) => {
// SAFETY: `k` comes from the hardcoded PATH_KEYED_TABLES allowlist.
let sql =
format!("SELECT EXISTS(SELECT 1 FROM {k} WHERE workspace_id = $1 AND path = $2)");
Some(
sqlx::query_scalar(&sql)
.bind(w_id)
.bind(path)
.fetch_one(db)
.await?,
)
}
_ => None,
};
Ok(exists.map(|exists| {
if exists {
DeployEventKind::Write
} else {
DeployEventKind::Delete
}
}))
}
#[cfg(test)]
mod tests {
use super::*;
use windmill_common::scripts::ScriptHash;
// --- DeployedObject::get_path tests ---
#[test]
fn test_get_path_script() {
let obj = DeployedObject::Script {
hash: ScriptHash(123),
path: "f/folder/script".to_string(),
parent_path: None,
};
assert_eq!(obj.get_path(), "f/folder/script");
}
#[test]
fn test_get_path_flow() {
let obj = DeployedObject::Flow {
path: "f/folder/flow".to_string(),
parent_path: Some("f/folder/old_flow".to_string()),
version: 1,
};
assert_eq!(obj.get_path(), "f/folder/flow");
}
#[test]
fn test_get_path_user() {
let obj = DeployedObject::User { email: "user@example.com".to_string() };
assert_eq!(obj.get_path(), "users/user@example.com");
}
#[test]
fn test_get_path_group() {
let obj = DeployedObject::Group { name: "admins".to_string() };
assert_eq!(obj.get_path(), "groups/admins");
}
#[test]
fn test_get_path_settings() {
let obj = DeployedObject::Settings { setting_type: "error_handler".to_string() };
assert_eq!(obj.get_path(), "settings.yaml");
}
#[test]
fn test_get_path_key() {
let obj = DeployedObject::Key { key_type: "encryption".to_string() };
assert_eq!(obj.get_path(), "encryption_key.yaml");
}
#[test]
fn test_get_path_workspace_dependencies() {
let obj = DeployedObject::WorkspaceDependencies {
path: "workspace-dependencies/python".to_string(),
};
assert_eq!(obj.get_path(), "workspace-dependencies/python");
}
// --- DeployedObject::get_git_sync_path tests ---
#[test]
fn test_get_git_sync_path_datatable_migration_is_repo_relative() {
let obj = DeployedObject::DatatableMigration {
path: "mydb/20260101000000_add_users".to_string(),
};
// The object path keys `workspace_diff`; the git-sync path must carry the
// export's prefix or the CLI stages nothing.
assert_eq!(obj.get_path(), "mydb/20260101000000_add_users");
assert_eq!(
obj.get_git_sync_path(),
"migrations/datatable/mydb/20260101000000_add_users"
);
}
#[test]
fn test_get_git_sync_path_defaults_to_object_path() {
let obj = DeployedObject::Script {
hash: ScriptHash(123),
path: "f/folder/script".to_string(),
parent_path: None,
};
assert_eq!(obj.get_git_sync_path(), obj.get_path());
}
// --- DeployedObject::get_ignore_regex_filter tests ---
#[test]
fn test_ignore_regex_filter_user() {
let obj = DeployedObject::User { email: "user@example.com".to_string() };
assert!(obj.get_ignore_regex_filter());
}
#[test]
fn test_ignore_regex_filter_group() {
let obj = DeployedObject::Group { name: "admins".to_string() };
assert!(obj.get_ignore_regex_filter());
}
#[test]
fn test_ignore_regex_filter_resource_type() {
let obj = DeployedObject::ResourceType { path: "postgresql".to_string() };
assert!(obj.get_ignore_regex_filter());
}
#[test]
fn test_ignore_regex_filter_settings() {
let obj = DeployedObject::Settings { setting_type: "error_handler".to_string() };
assert!(obj.get_ignore_regex_filter());
}
#[test]
fn test_ignore_regex_filter_key() {
let obj = DeployedObject::Key { key_type: "encryption".to_string() };
assert!(obj.get_ignore_regex_filter());
}
#[test]
fn test_ignore_regex_filter_workspace_dependencies() {
let obj = DeployedObject::WorkspaceDependencies {
path: "workspace-dependencies/python".to_string(),
};
assert!(obj.get_ignore_regex_filter());
}
#[test]
fn test_ignore_regex_filter_datatable_migration() {
// `migrations/datatable/**` is outside the `f/`/`u/` namespaces the path
// filters are written against; the `datatablemigration` include type is
// what governs these.
let obj = DeployedObject::DatatableMigration {
path: "mydb/20260101000000_add_users".to_string(),
};
assert!(obj.get_ignore_regex_filter());
}
#[test]
fn test_ignore_regex_filter_script() {
let obj = DeployedObject::Script {
hash: ScriptHash(123),
path: "f/folder/script".to_string(),
parent_path: None,
};
assert!(!obj.get_ignore_regex_filter());
}
#[test]
fn test_ignore_regex_filter_flow() {
let obj = DeployedObject::Flow {
path: "f/folder/flow".to_string(),
parent_path: None,
version: 1,
};
assert!(!obj.get_ignore_regex_filter());
}
// --- DeployedObject::get_parent_path tests ---
#[test]
fn test_get_parent_path_script_with_parent() {
let obj = DeployedObject::Script {
hash: ScriptHash(123),
path: "f/folder/script".to_string(),
parent_path: Some("f/folder/old_script".to_string()),
};
assert_eq!(
obj.get_parent_path(),
Some("f/folder/old_script".to_string())
);
}
#[test]
fn test_get_parent_path_script_without_parent() {
let obj = DeployedObject::Script {
hash: ScriptHash(123),
path: "f/folder/script".to_string(),
parent_path: None,
};
assert_eq!(obj.get_parent_path(), None);
}
#[test]
fn test_get_parent_path_folder() {
let obj = DeployedObject::Folder { path: "f/folder".to_string() };
assert_eq!(obj.get_parent_path(), None);
}
#[test]
fn test_get_parent_path_workspace_dependencies() {
let obj = DeployedObject::WorkspaceDependencies {
path: "workspace-dependencies/python".to_string(),
};
assert_eq!(obj.get_parent_path(), None);
}
// --- DeployedObject::get_kind tests ---
#[test]
fn test_get_kind_script() {
let obj = DeployedObject::Script {
hash: ScriptHash(123),
path: "test".to_string(),
parent_path: None,
};
assert_eq!(obj.get_kind(), "script");
}
#[test]
fn test_get_kind_flow() {
let obj = DeployedObject::Flow { path: "test".to_string(), parent_path: None, version: 1 };
assert_eq!(obj.get_kind(), "flow");
}
#[test]
fn test_get_kind_app() {
let obj = DeployedObject::App { path: "test".to_string(), version: 1, parent_path: None };
assert_eq!(obj.get_kind(), "app");
}
#[test]
fn test_get_kind_workspace_dependencies() {
let obj = DeployedObject::WorkspaceDependencies {
path: "workspace-dependencies/python".to_string(),
};
assert_eq!(obj.get_kind(), "workspace_dependencies");
}
#[test]
fn test_get_kind_all_triggers() {
assert_eq!(
DeployedObject::HttpTrigger { path: "t".to_string(), parent_path: None }.get_kind(),
"http_trigger"
);
assert_eq!(
DeployedObject::WebsocketTrigger { path: "t".to_string(), parent_path: None }
.get_kind(),
"websocket_trigger"
);
assert_eq!(
DeployedObject::KafkaTrigger { path: "t".to_string(), parent_path: None }.get_kind(),
"kafka_trigger"
);
assert_eq!(
DeployedObject::NatsTrigger { path: "t".to_string(), parent_path: None }.get_kind(),
"nats_trigger"
);
assert_eq!(
DeployedObject::PostgresTrigger { path: "t".to_string(), parent_path: None }.get_kind(),
"postgres_trigger"
);
assert_eq!(
DeployedObject::MqttTrigger { path: "t".to_string(), parent_path: None }.get_kind(),
"mqtt_trigger"
);
assert_eq!(
DeployedObject::AmqpTrigger { path: "t".to_string(), parent_path: None }.get_kind(),
"amqp_trigger"
);
assert_eq!(
DeployedObject::SqsTrigger { path: "t".to_string(), parent_path: None }.get_kind(),
"sqs_trigger"
);
assert_eq!(
DeployedObject::GcpTrigger { path: "t".to_string(), parent_path: None }.get_kind(),
"gcp_trigger"
);
assert_eq!(
DeployedObject::AzureTrigger { path: "t".to_string(), parent_path: None }.get_kind(),
"azure_trigger"
);
assert_eq!(
DeployedObject::EmailTrigger { path: "t".to_string(), parent_path: None }.get_kind(),
"email_trigger"
);
}
}