diff --git a/backend/.sqlx/query-05de4eda0c70e1fec7d1c1f996971ad550377ddca9ce98031a49eecb4a8e4c9d.json b/backend/.sqlx/query-05de4eda0c70e1fec7d1c1f996971ad550377ddca9ce98031a49eecb4a8e4c9d.json deleted file mode 100644 index 9215b66c97..0000000000 --- a/backend/.sqlx/query-05de4eda0c70e1fec7d1c1f996971ad550377ddca9ce98031a49eecb4a8e4c9d.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT path, typ, value as \"value!: sqlx::types::Json>\", created_at\n FROM draft\n WHERE workspace_id = $1\n AND email = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "typ", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "value!: sqlx::types::Json>", - "type_info": "Json" - }, - { - "ordinal": 3, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "05de4eda0c70e1fec7d1c1f996971ad550377ddca9ce98031a49eecb4a8e4c9d" -} diff --git a/backend/.sqlx/query-0f7ad07f2bef7e55d6fc15db11d420468770630c1805058edd14679062162b8c.json b/backend/.sqlx/query-0f7ad07f2bef7e55d6fc15db11d420468770630c1805058edd14679062162b8c.json index e27ca74d56..28bb8fd4de 100644 --- a/backend/.sqlx/query-0f7ad07f2bef7e55d6fc15db11d420468770630c1805058edd14679062162b8c.json +++ b/backend/.sqlx/query-0f7ad07f2bef7e55d6fc15db11d420468770630c1805058edd14679062162b8c.json @@ -8,7 +8,39 @@ "Text", "Text", "Text", - "Text" + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + } ] }, "nullable": [] diff --git a/backend/.sqlx/query-29190ba48f4986eaf35247e9754bcd6885cc499823ee1d52bf2631dd3413f310.json b/backend/.sqlx/query-29190ba48f4986eaf35247e9754bcd6885cc499823ee1d52bf2631dd3413f310.json index ce13fd5351..faddf0a546 100644 --- a/backend/.sqlx/query-29190ba48f4986eaf35247e9754bcd6885cc499823ee1d52bf2631dd3413f310.json +++ b/backend/.sqlx/query-29190ba48f4986eaf35247e9754bcd6885cc499823ee1d52bf2631dd3413f310.json @@ -14,7 +14,39 @@ "Varchar", "Varchar", "Varchar", - "Varchar", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + }, "Text" ] }, diff --git a/backend/.sqlx/query-2d8657ce809262cc261a381bcb19549970aa645f9e23c2525311fc1161fd21ff.json b/backend/.sqlx/query-2d8657ce809262cc261a381bcb19549970aa645f9e23c2525311fc1161fd21ff.json index 704cfd6576..12393c6ebb 100644 --- a/backend/.sqlx/query-2d8657ce809262cc261a381bcb19549970aa645f9e23c2525311fc1161fd21ff.json +++ b/backend/.sqlx/query-2d8657ce809262cc261a381bcb19549970aa645f9e23c2525311fc1161fd21ff.json @@ -19,7 +19,39 @@ "Text", "Text", "Text", - "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + }, "Timestamptz" ] }, diff --git a/backend/.sqlx/query-6aa60770e0ca16527354aae1593279cd7ada3a44a4157fbad52890ce5896174d.json b/backend/.sqlx/query-6aa60770e0ca16527354aae1593279cd7ada3a44a4157fbad52890ce5896174d.json new file mode 100644 index 0000000000..61bbf73af8 --- /dev/null +++ b/backend/.sqlx/query-6aa60770e0ca16527354aae1593279cd7ada3a44a4157fbad52890ce5896174d.json @@ -0,0 +1,73 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path,\n typ as \"typ!: UserDraftItemKind\",\n value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND email = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "typ!: UserDraftItemKind", + "type_info": { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + } + }, + { + "ordinal": 2, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "6aa60770e0ca16527354aae1593279cd7ada3a44a4157fbad52890ce5896174d" +} diff --git a/backend/.sqlx/query-815d96aea4681490582b08630a30a168cc1191acaab96bed6a016c437059c2cd.json b/backend/.sqlx/query-815d96aea4681490582b08630a30a168cc1191acaab96bed6a016c437059c2cd.json new file mode 100644 index 0000000000..28a725277d --- /dev/null +++ b/backend/.sqlx/query-815d96aea4681490582b08630a30a168cc1191acaab96bed6a016c437059c2cd.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT ws.workspace_id AS \"workspace_id!\", entry->'catalog'->>'resource_path' AS dbname\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(\n CASE WHEN jsonb_typeof(ws.ducklake->'ducklakes') = 'object'\n THEN ws.ducklake->'ducklakes'\n ELSE '{}'::jsonb END\n ) AS dl(k, entry)\n WHERE entry->'catalog'->>'resource_type' = 'instance'\n AND entry->'catalog'->>'resource_path' IS NOT NULL\n UNION ALL\n SELECT ws.workspace_id AS \"workspace_id!\", entry->'database'->>'resource_path' AS dbname\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(\n CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object'\n THEN ws.datatable->'datatables'\n ELSE '{}'::jsonb END\n ) AS dt(k, entry)\n WHERE entry->'database'->>'resource_type' = 'instance'\n AND entry->'database'->>'resource_path' IS NOT NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "dbname", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + null + ] + }, + "hash": "815d96aea4681490582b08630a30a168cc1191acaab96bed6a016c437059c2cd" +} diff --git a/backend/.sqlx/query-b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d.json b/backend/.sqlx/query-b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d.json new file mode 100644 index 0000000000..52e1ced19a --- /dev/null +++ b/backend/.sqlx/query-b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d.json @@ -0,0 +1,63 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value as \"value!: sqlx::types::Json>\", created_at\n FROM draft\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $3\n AND email IS NOT DISTINCT FROM $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d" +} diff --git a/backend/.sqlx/query-c5c5ee9abb457ffdf34d097738134ab160ea1340aa65c4d1d3963ba384b8d39b.json b/backend/.sqlx/query-c5c5ee9abb457ffdf34d097738134ab160ea1340aa65c4d1d3963ba384b8d39b.json new file mode 100644 index 0000000000..3e9c78ccbb --- /dev/null +++ b/backend/.sqlx/query-c5c5ee9abb457ffdf34d097738134ab160ea1340aa65c4d1d3963ba384b8d39b.json @@ -0,0 +1,74 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path,\n typ as \"typ!: UserDraftItemKind\",\n value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND email = $2\n AND created_at > $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "typ!: UserDraftItemKind", + "type_info": { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + } + }, + { + "ordinal": 2, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "c5c5ee9abb457ffdf34d097738134ab160ea1340aa65c4d1d3963ba384b8d39b" +} diff --git a/backend/.sqlx/query-ca70f58d3c301c5b31749ea753dd1f63989c2fdae78e7e33053986862853dda5.json b/backend/.sqlx/query-ca70f58d3c301c5b31749ea753dd1f63989c2fdae78e7e33053986862853dda5.json index f3be85fe02..4796901a81 100644 --- a/backend/.sqlx/query-ca70f58d3c301c5b31749ea753dd1f63989c2fdae78e7e33053986862853dda5.json +++ b/backend/.sqlx/query-ca70f58d3c301c5b31749ea753dd1f63989c2fdae78e7e33053986862853dda5.json @@ -18,7 +18,39 @@ "Left": [ "Text", "Text", - "Text" + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + } ] }, "nullable": [ diff --git a/backend/.sqlx/query-f3afdf5898105679c9e032269a94e9d2dc904718e67a3583ae6bedd1c646a05c.json b/backend/.sqlx/query-f3afdf5898105679c9e032269a94e9d2dc904718e67a3583ae6bedd1c646a05c.json deleted file mode 100644 index 0430a3d9aa..0000000000 --- a/backend/.sqlx/query-f3afdf5898105679c9e032269a94e9d2dc904718e67a3583ae6bedd1c646a05c.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT path, typ, value as \"value!: sqlx::types::Json>\", created_at\n FROM draft\n WHERE workspace_id = $1\n AND email = $2\n AND created_at > $3", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "typ", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "value!: sqlx::types::Json>", - "type_info": "Json" - }, - { - "ordinal": 3, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Timestamptz" - ] - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "f3afdf5898105679c9e032269a94e9d2dc904718e67a3583ae6bedd1c646a05c" -} diff --git a/backend/migrations/20260527222115_add_username_to_draft.down.sql b/backend/migrations/20260527222115_add_username_to_draft.down.sql deleted file mode 100644 index 702810fbc2..0000000000 --- a/backend/migrations/20260527222115_add_username_to_draft.down.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Add down migration script here -DROP INDEX IF EXISTS draft_pkey_legacy; -DROP INDEX IF EXISTS draft_pkey_with_user; - -DELETE FROM draft WHERE username IS NOT NULL; - -ALTER TABLE draft ADD CONSTRAINT draft_pkey PRIMARY KEY (workspace_id, path, typ); - -ALTER TABLE draft DROP CONSTRAINT IF EXISTS draft_usr_fkey; - -ALTER TABLE draft DROP COLUMN username; diff --git a/backend/migrations/20260527222115_add_username_to_draft.up.sql b/backend/migrations/20260527222115_add_username_to_draft.up.sql deleted file mode 100644 index 86cd367740..0000000000 --- a/backend/migrations/20260527222115_add_username_to_draft.up.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Add up migration script here -ALTER TABLE draft ADD COLUMN username VARCHAR(50); - -ALTER TABLE draft - ADD CONSTRAINT draft_usr_fkey - FOREIGN KEY (workspace_id, username) - REFERENCES usr(workspace_id, username) - ON DELETE CASCADE - ON UPDATE CASCADE; - -ALTER TABLE draft DROP CONSTRAINT draft_pkey; - -CREATE UNIQUE INDEX draft_pkey_with_user - ON draft (workspace_id, path, typ, username) - WHERE username IS NOT NULL; - -CREATE UNIQUE INDEX draft_pkey_legacy - ON draft (workspace_id, path, typ) - WHERE username IS NULL; diff --git a/backend/migrations/20260528091746_draft_typ_to_varchar_and_id.down.sql b/backend/migrations/20260528091746_draft_typ_to_varchar_and_id.down.sql deleted file mode 100644 index 16d2fd3d9d..0000000000 --- a/backend/migrations/20260528091746_draft_typ_to_varchar_and_id.down.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Add down migration script here -ALTER TABLE draft DROP COLUMN id; - --- Recreate the enum. Drop any rows whose typ falls outside the original --- enum values; without this the type cast below would fail. -DELETE FROM draft WHERE typ NOT IN ('script', 'flow', 'app'); -CREATE TYPE DRAFT_TYPE AS ENUM ('script', 'flow', 'app'); -ALTER TABLE draft ALTER COLUMN typ TYPE DRAFT_TYPE USING typ::DRAFT_TYPE; diff --git a/backend/migrations/20260528091746_draft_typ_to_varchar_and_id.up.sql b/backend/migrations/20260528091746_draft_typ_to_varchar_and_id.up.sql deleted file mode 100644 index 4aec234965..0000000000 --- a/backend/migrations/20260528091746_draft_typ_to_varchar_and_id.up.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Add up migration script here --- Loosen draft.typ from the DRAFT_TYPE enum to a plain VARCHAR so the --- bidirectional sync layer can store drafts for any UserDraft item kind --- (raw_app, resource, variable, trigger_*, ...) without an enum migration --- per kind. -ALTER TABLE draft ALTER COLUMN typ TYPE VARCHAR(50) USING typ::text; -DROP TYPE DRAFT_TYPE; - --- Give the draft table a proper synthetic primary key. The previous --- migration replaced the composite PK with two partial unique indexes --- (one for user-scoped rows, one for legacy NULL-username rows); having --- no PK at all breaks tools that assume one (pg_dump, replication, ORM --- drift detection). -ALTER TABLE draft ADD COLUMN id BIGSERIAL PRIMARY KEY; diff --git a/backend/migrations/20260528092124_switch_draft_username_to_email.down.sql b/backend/migrations/20260528092124_switch_draft_username_to_email.down.sql deleted file mode 100644 index 0247409404..0000000000 --- a/backend/migrations/20260528092124_switch_draft_username_to_email.down.sql +++ /dev/null @@ -1,34 +0,0 @@ --- Add down migration script here -ALTER TABLE draft DROP CONSTRAINT IF EXISTS draft_password_fkey; -DROP INDEX IF EXISTS draft_pkey_with_user; - --- Best-effort: map back to a workspace username if the email matches a --- single `usr` row. Drafts whose email doesn't resolve to a usr row are --- dropped — the previous schema had no way to represent them. -UPDATE draft -SET email = usr.username -FROM usr -WHERE draft.workspace_id = usr.workspace_id - AND draft.email = usr.email; - -DELETE FROM draft -WHERE email IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM usr - WHERE usr.workspace_id = draft.workspace_id - AND usr.username = draft.email - ); - -ALTER TABLE draft RENAME COLUMN email TO username; -ALTER TABLE draft ALTER COLUMN username TYPE VARCHAR(50); - -ALTER TABLE draft - ADD CONSTRAINT draft_usr_fkey - FOREIGN KEY (workspace_id, username) - REFERENCES usr(workspace_id, username) - ON DELETE CASCADE - ON UPDATE CASCADE; - -CREATE UNIQUE INDEX draft_pkey_with_user - ON draft (workspace_id, path, typ, username) - WHERE username IS NOT NULL; diff --git a/backend/migrations/20260528092124_switch_draft_username_to_email.up.sql b/backend/migrations/20260528092124_switch_draft_username_to_email.up.sql deleted file mode 100644 index 81f59e14e4..0000000000 --- a/backend/migrations/20260528092124_switch_draft_username_to_email.up.sql +++ /dev/null @@ -1,28 +0,0 @@ --- Add up migration script here --- Switch draft ownership from per-workspace `username` (FK to `usr`) to --- per-instance `email` (FK to `password`). Email is always populated on --- the authed session (including super-admins viewing foreign workspaces --- they aren't members of), so the FK no longer blocks legitimate writes. -ALTER TABLE draft DROP CONSTRAINT IF EXISTS draft_usr_fkey; - -DROP INDEX IF EXISTS draft_pkey_with_user; - -ALTER TABLE draft RENAME COLUMN username TO email; -ALTER TABLE draft ALTER COLUMN email TYPE VARCHAR(255); - -UPDATE draft -SET email = usr.email -FROM usr -WHERE draft.workspace_id = usr.workspace_id - AND draft.email = usr.username; - -ALTER TABLE draft - ADD CONSTRAINT draft_password_fkey - FOREIGN KEY (email) - REFERENCES password(email) - ON DELETE CASCADE - ON UPDATE CASCADE; - -CREATE UNIQUE INDEX draft_pkey_with_user - ON draft (workspace_id, path, typ, email) - WHERE email IS NOT NULL; diff --git a/backend/migrations/20260528143710_draft_user_sync_schema.down.sql b/backend/migrations/20260528143710_draft_user_sync_schema.down.sql new file mode 100644 index 0000000000..b458f05d31 --- /dev/null +++ b/backend/migrations/20260528143710_draft_user_sync_schema.down.sql @@ -0,0 +1,20 @@ +ALTER TABLE draft DROP COLUMN id; + +DROP INDEX IF EXISTS draft_pkey_legacy; +DROP INDEX IF EXISTS draft_pkey_with_user; + +-- Per-user rows can't be represented in the pre-sync schema (one row per +-- (workspace_id, path, typ)). Drop them before restoring the composite PK. +DELETE FROM draft WHERE email IS NOT NULL; + +ALTER TABLE draft ADD CONSTRAINT draft_pkey PRIMARY KEY (workspace_id, path, typ); + +ALTER TABLE draft DROP CONSTRAINT IF EXISTS draft_password_fkey; +ALTER TABLE draft DROP COLUMN email; + +-- Restore the narrower DRAFT_TYPE enum (script/flow/app only). Drop any +-- rows whose kind falls outside that set so the cast doesn't fail. +CREATE TYPE DRAFT_TYPE AS ENUM ('script', 'flow', 'app'); +DELETE FROM draft WHERE typ::text NOT IN ('script', 'flow', 'app'); +ALTER TABLE draft ALTER COLUMN typ TYPE DRAFT_TYPE USING typ::text::DRAFT_TYPE; +DROP TYPE DRAFT_KIND; diff --git a/backend/migrations/20260528143710_draft_user_sync_schema.up.sql b/backend/migrations/20260528143710_draft_user_sync_schema.up.sql new file mode 100644 index 0000000000..ed0b389c16 --- /dev/null +++ b/backend/migrations/20260528143710_draft_user_sync_schema.up.sql @@ -0,0 +1,64 @@ +-- Reshape `draft` for per-user bidirectional sync: +-- * add `email` (FK to `password.email`) — owner of the draft. NULL on +-- legacy rows written before per-user sync existed. +-- * replace the composite PK with two partial unique indexes so per-user +-- rows and the single legacy workspace-level row can coexist at the +-- same (workspace_id, path, typ). +-- * replace the DRAFT_TYPE enum (script/flow/app only) with DRAFT_KIND, +-- covering every UserDraftItemKind the sync layer accepts. Keeping it +-- as an enum (rather than VARCHAR) lets the type system reject typos +-- at the DB boundary and stays in sync with the Rust `UserDraftItemKind`. +-- * give the table a synthetic BIGSERIAL `id` PK — tools that assume a +-- real PK (pg_dump, replication, ORM drift detection) break on the +-- partial-index-only layout above. + +CREATE TYPE DRAFT_KIND AS ENUM ( + 'script', + 'flow', + 'app', + 'raw_app', + 'resource', + 'variable', + 'trigger_schedule', + 'trigger_webhook', + 'trigger_default_email', + 'trigger_email', + 'trigger_http', + 'trigger_websocket', + 'trigger_postgres', + 'trigger_kafka', + 'trigger_nats', + 'trigger_mqtt', + 'trigger_sqs', + 'trigger_gcp', + 'trigger_azure', + 'trigger_poll', + 'trigger_cli', + 'trigger_nextcloud', + 'trigger_google', + 'trigger_github' +); + +ALTER TABLE draft ALTER COLUMN typ TYPE DRAFT_KIND USING typ::text::DRAFT_KIND; +DROP TYPE DRAFT_TYPE; + +ALTER TABLE draft ADD COLUMN email VARCHAR(255); + +ALTER TABLE draft + ADD CONSTRAINT draft_password_fkey + FOREIGN KEY (email) + REFERENCES password(email) + ON DELETE CASCADE + ON UPDATE CASCADE; + +ALTER TABLE draft DROP CONSTRAINT draft_pkey; + +CREATE UNIQUE INDEX draft_pkey_with_user + ON draft (workspace_id, path, typ, email) + WHERE email IS NOT NULL; + +CREATE UNIQUE INDEX draft_pkey_legacy + ON draft (workspace_id, path, typ) + WHERE email IS NULL; + +ALTER TABLE draft ADD COLUMN id BIGSERIAL PRIMARY KEY; diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 8c45d89241..c101627079 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -7829,8 +7829,7 @@ paths: path: type: string typ: - type: string - description: UserDraft item kind. Stored verbatim; the client owns the value set. + $ref: "#/components/schemas/UserDraftItemKind" value: nullable: true description: Draft content. `null` (or omitted) means delete the draft at this path. Conflict semantics apply the same way to deletes. @@ -7855,7 +7854,7 @@ paths: path: type: string typ: - type: string + $ref: "#/components/schemas/UserDraftItemKind" value: {} created_at: type: string @@ -7873,7 +7872,7 @@ paths: path: type: string typ: - type: string + $ref: "#/components/schemas/UserDraftItemKind" created_at: type: string format: date-time @@ -7886,7 +7885,7 @@ paths: path: type: string typ: - type: string + $ref: "#/components/schemas/UserDraftItemKind" required: [status, path, typ] - type: object properties: @@ -7896,7 +7895,7 @@ paths: path: type: string typ: - type: string + $ref: "#/components/schemas/UserDraftItemKind" server_value: {} server_created_at: type: string @@ -7927,7 +7926,7 @@ paths: in: path required: true schema: - type: string + $ref: "#/components/schemas/UserDraftItemKind" - $ref: "#/components/parameters/ScriptPath" responses: "200": @@ -7959,7 +7958,7 @@ paths: in: path required: true schema: - type: string + $ref: "#/components/schemas/UserDraftItemKind" - $ref: "#/components/parameters/ScriptPath" - name: email in: query @@ -20936,6 +20935,36 @@ components: # This is why it is better to inline each of schemas for better compat # Do not change next line. It is used by python-client for pre-processing # -- INLINE START -- + UserDraftItemKind: + type: string + description: | + Closed set of item kinds a user can autosave as a draft. Mirrors the + Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`. + enum: + - script + - flow + - app + - raw_app + - resource + - variable + - trigger_schedule + - trigger_webhook + - trigger_default_email + - trigger_email + - trigger_http + - trigger_websocket + - trigger_postgres + - trigger_kafka + - trigger_nats + - trigger_mqtt + - trigger_sqs + - trigger_gcp + - trigger_azure + - trigger_poll + - trigger_cli + - trigger_nextcloud + - trigger_google + - trigger_github OpenFlow: $ref: "../../openflow.openapi.yaml#/components/schemas/OpenFlow" FlowValue: diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index b820b14b35..6e3532da37 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -19,6 +19,44 @@ use windmill_common::{ error::{Error, Result}, }; +/// Closed set of item kinds a user can have an autosaved draft on. Mirrors +/// the frontend's `USER_DRAFT_ITEM_KINDS`; the Postgres `DRAFT_KIND` enum +/// must stay in lockstep — adding a kind requires both a new variant here +/// and an `ALTER TYPE ... ADD VALUE` migration. +/// +/// `snake_case` matches the wire/DB encoding so the same string round-trips +/// through HTTP path params, JSON bodies, and the `draft.typ` column without +/// per-edge mapping. +#[derive(sqlx::Type, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[sqlx(type_name = "DRAFT_KIND", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum UserDraftItemKind { + Script, + Flow, + App, + RawApp, + Resource, + Variable, + TriggerSchedule, + TriggerWebhook, + TriggerDefaultEmail, + TriggerEmail, + TriggerHttp, + TriggerWebsocket, + TriggerPostgres, + TriggerKafka, + TriggerNats, + TriggerMqtt, + TriggerSqs, + TriggerGcp, + TriggerAzure, + TriggerPoll, + TriggerCli, + TriggerNextcloud, + TriggerGoogle, + TriggerGithub, +} + pub fn workspaced_service() -> Router { Router::new() .route("/sync", post(sync_drafts)) @@ -32,11 +70,7 @@ pub fn workspaced_service() -> Router { #[derive(Deserialize, Debug, Clone)] pub struct IncomingDraft { pub path: String, - /// Free-form item kind matching the frontend's `UserDraftItemKind` - /// (`script`, `flow`, `app`, `raw_app`, `resource`, `variable`, - /// `trigger_*`, ...). Stored verbatim — no server-side validation, - /// since the set of kinds is owned by the client. - pub typ: String, + pub typ: UserDraftItemKind, /// `null` (or omitted) means delete the draft at this path. Conflict /// semantics apply the same way to deletions as to upserts. #[serde(default)] @@ -61,7 +95,7 @@ pub struct SyncDraftsRequest { #[derive(Serialize, Debug)] pub struct MissedDraft { pub path: String, - pub typ: String, + pub typ: UserDraftItemKind, pub value: sqlx::types::Json>, pub created_at: chrono::DateTime, } @@ -71,16 +105,16 @@ pub struct MissedDraft { pub enum DraftSyncStatus { Saved { path: String, - typ: String, + typ: UserDraftItemKind, created_at: chrono::DateTime, }, Deleted { path: String, - typ: String, + typ: UserDraftItemKind, }, Rejected { path: String, - typ: String, + typ: UserDraftItemKind, /// Current server copy at conflict-detection time. server_value: sqlx::types::Json>, server_created_at: chrono::DateTime, @@ -109,7 +143,10 @@ async fn sync_drafts( let missed_drafts = if let Some(last_sync) = req.last_sync { sqlx::query_as!( MissedDraft, - r#"SELECT path, typ, value as "value!: sqlx::types::Json>", created_at + r#"SELECT path, + typ as "typ!: UserDraftItemKind", + value as "value!: sqlx::types::Json>", + created_at FROM draft WHERE workspace_id = $1 AND email = $2 @@ -124,7 +161,10 @@ async fn sync_drafts( // Initial sync — return everything the user has on the server. sqlx::query_as!( MissedDraft, - r#"SELECT path, typ, value as "value!: sqlx::types::Json>", created_at + r#"SELECT path, + typ as "typ!: UserDraftItemKind", + value as "value!: sqlx::types::Json>", + created_at FROM draft WHERE workspace_id = $1 AND email = $2"#, @@ -151,7 +191,7 @@ async fn sync_drafts( &w_id, email, incoming.path, - incoming.typ, + incoming.typ as UserDraftItemKind, last_sync, ) .fetch_optional(&db) @@ -160,7 +200,7 @@ async fn sync_drafts( if let Some(row) = conflict { statuses.push(DraftSyncStatus::Rejected { path: incoming.path.clone(), - typ: incoming.typ.clone(), + typ: incoming.typ, server_value: row.value, server_created_at: row.created_at, incoming_value: incoming.value.as_ref().map(|v| { @@ -186,7 +226,7 @@ async fn sync_drafts( &w_id, email, incoming.path, - incoming.typ, + incoming.typ as UserDraftItemKind, serde_json::to_string(value).unwrap(), ) .fetch_one(&db) @@ -194,7 +234,7 @@ async fn sync_drafts( statuses.push(DraftSyncStatus::Saved { path: incoming.path.clone(), - typ: incoming.typ.clone(), + typ: incoming.typ, created_at: row.created_at, }); } @@ -212,14 +252,14 @@ async fn sync_drafts( &w_id, email, incoming.path, - incoming.typ, + incoming.typ as UserDraftItemKind, ) .execute(&db) .await?; statuses.push(DraftSyncStatus::Deleted { path: incoming.path.clone(), - typ: incoming.typ.clone(), + typ: incoming.typ, }); } } @@ -252,10 +292,10 @@ async fn list_users_with_draft_on_path( authed: ApiAuthed, Extension(db): Extension, Extension(user_db): Extension, - Path((w_id, kind, path)): Path<(String, String, windmill_common::utils::StripPath)>, + Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>, ) -> Result>> { let path = path.to_path(); - require_can_read_path(&authed, &user_db, &w_id, &kind, path).await?; + require_can_read_path(&authed, &user_db, &w_id, kind, path).await?; let rows = sqlx::query_as!( UserWithDraft, @@ -267,7 +307,7 @@ async fn list_users_with_draft_on_path( ORDER BY email NULLS LAST"#, &w_id, path, - kind, + kind as UserDraftItemKind, ) .fetch_all(&db) .await?; @@ -296,11 +336,11 @@ async fn get_draft_for_user( authed: ApiAuthed, Extension(db): Extension, Extension(user_db): Extension, - Path((w_id, kind, path)): Path<(String, String, windmill_common::utils::StripPath)>, + Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>, axum::extract::Query(query): axum::extract::Query, ) -> Result> { let path = path.to_path(); - require_can_read_path(&authed, &user_db, &w_id, &kind, path).await?; + require_can_read_path(&authed, &user_db, &w_id, kind, path).await?; let row = sqlx::query_as!( DraftForUser, @@ -312,7 +352,7 @@ async fn get_draft_for_user( AND email IS NOT DISTINCT FROM $4"#, &w_id, path, - kind, + kind as UserDraftItemKind, query.email, ) .fetch_optional(&db) @@ -330,28 +370,30 @@ async fn get_draft_for_user( /// resolve item-level extra_perms grants that bypass folder/owner checks) /// or `None` (kinds without a backing table fall through to the path-only /// access check below). -fn table_for_kind(kind: &str) -> Option<&'static str> { +fn table_for_kind(kind: UserDraftItemKind) -> Option<&'static str> { + use UserDraftItemKind::*; match kind { - "script" => Some("script"), - "flow" => Some("flow"), - "app" | "raw_app" => Some("app"), - "resource" => Some("resource"), - "variable" => Some("variable"), - "trigger_schedule" => Some("schedule"), - "trigger_http" => Some("http_trigger"), - "trigger_websocket" => Some("websocket_trigger"), - "trigger_postgres" => Some("postgres_trigger"), - "trigger_kafka" => Some("kafka_trigger"), - "trigger_nats" => Some("nats_trigger"), - "trigger_mqtt" => Some("mqtt_trigger"), - "trigger_sqs" => Some("sqs_trigger"), - "trigger_gcp" => Some("gcp_trigger"), - "trigger_azure" => Some("azure_trigger"), - "trigger_email" | "trigger_default_email" => Some("email_trigger"), - "trigger_poll" | "trigger_cli" | "trigger_nextcloud" | "trigger_google" - | "trigger_github" => Some("native_trigger"), + Script => Some("script"), + Flow => Some("flow"), + App | RawApp => Some("app"), + Resource => Some("resource"), + Variable => Some("variable"), + TriggerSchedule => Some("schedule"), + TriggerHttp => Some("http_trigger"), + TriggerWebsocket => Some("websocket_trigger"), + TriggerPostgres => Some("postgres_trigger"), + TriggerKafka => Some("kafka_trigger"), + TriggerNats => Some("nats_trigger"), + TriggerMqtt => Some("mqtt_trigger"), + TriggerSqs => Some("sqs_trigger"), + TriggerGcp => Some("gcp_trigger"), + TriggerAzure => Some("azure_trigger"), + TriggerEmail | TriggerDefaultEmail => Some("email_trigger"), + TriggerPoll | TriggerCli | TriggerNextcloud | TriggerGoogle | TriggerGithub => { + Some("native_trigger") + } // trigger_webhook is a property of script/flow rows, not its own row. - _ => None, + TriggerWebhook => None, } } @@ -370,7 +412,7 @@ async fn require_can_read_path( authed: &ApiAuthed, user_db: &UserDB, w_id: &str, - kind: &str, + kind: UserDraftItemKind, path: &str, ) -> Result<()> { if authed.is_admin { diff --git a/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte b/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte index 69f410e56f..5446c466f6 100644 --- a/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte +++ b/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte @@ -16,7 +16,7 @@ import { fade } from 'svelte/transition' import Button from '../button/Button.svelte' import { Users, X } from 'lucide-svelte' - import { DraftService } from '$lib/gen' + import { DraftService, type UserDraftItemKind } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { onMount } from 'svelte' import type DiffDrawer from '$lib/components/DiffDrawer.svelte' @@ -27,7 +27,7 @@ workspace: string /** UserDraft item kind — passed verbatim to the backend; matches the * `draft.typ` column. */ - itemKind: string + itemKind: UserDraftItemKind path: string /** Current local value (post-restore-from-localstorage) shown on the * right side of the diff. */ diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 8e284256b3..1e6d2faa27 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -5,7 +5,14 @@ import { userStore, workspaceStore } from './stores' import { useLocalStorageValue } from './svelte5Utils.svelte' import { UserDraftDbSyncer } from './userDraftDbSyncer.svelte' import { UserDraftConflictStore } from './userDraftConflictStore.svelte' +import type { UserDraftItemKind } from './gen' +export type { UserDraftItemKind } + +// Runtime array mirroring the generated `UserDraftItemKind` union. Used for +// iteration in `tryParseLocalStorageKey`. The `satisfies` clause makes the +// compiler reject this file if the two drift — adding a kind to the OpenAPI +// schema without listing it here (or vice-versa) is a type error. export const USER_DRAFT_ITEM_KINDS = [ 'script', 'flow', @@ -31,9 +38,14 @@ export const USER_DRAFT_ITEM_KINDS = [ 'trigger_nextcloud', 'trigger_google', 'trigger_github' -] as const +] as const satisfies readonly UserDraftItemKind[] -export type UserDraftItemKind = (typeof USER_DRAFT_ITEM_KINDS)[number] +// And the reverse direction: every member of the generated union must +// appear in `USER_DRAFT_ITEM_KINDS`. +type _AssertKindsExhaustive = + Exclude extends never ? true : never +const _: _AssertKindsExhaustive = true +void _ export type UserDraftOptions = { workspace?: string