refactor: unify draft schema migrations and type kinds via DRAFT_KIND enum

This commit is contained in:
Diego Imbert
2026-05-28 16:51:30 +02:00
parent 9a20bebff4
commit b236f93a90
22 changed files with 591 additions and 257 deletions
@@ -1,41 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path, typ, value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\", 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<Box<serde_json::value::RawValue>>",
"type_info": "Json"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "05de4eda0c70e1fec7d1c1f996971ad550377ddca9ce98031a49eecb4a8e4c9d"
}
@@ -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": []
@@ -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"
]
},
@@ -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"
]
},
@@ -0,0 +1,73 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path,\n typ as \"typ!: UserDraftItemKind\",\n value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\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<Box<serde_json::value::RawValue>>",
"type_info": "Json"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "6aa60770e0ca16527354aae1593279cd7ada3a44a4157fbad52890ce5896174d"
}
@@ -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"
}
@@ -0,0 +1,63 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\", 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<Box<serde_json::value::RawValue>>",
"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"
}
@@ -0,0 +1,74 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path,\n typ as \"typ!: UserDraftItemKind\",\n value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\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<Box<serde_json::value::RawValue>>",
"type_info": "Json"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Timestamptz"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "c5c5ee9abb457ffdf34d097738134ab160ea1340aa65c4d1d3963ba384b8d39b"
}
@@ -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": [
@@ -1,42 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path, typ, value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\", 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<Box<serde_json::value::RawValue>>",
"type_info": "Json"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Timestamptz"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "f3afdf5898105679c9e032269a94e9d2dc904718e67a3583ae6bedd1c646a05c"
}
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
+37 -8
View File
@@ -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:
+86 -44
View File
@@ -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<Box<serde_json::value::RawValue>>,
pub created_at: chrono::DateTime<chrono::Utc>,
}
@@ -71,16 +105,16 @@ pub struct MissedDraft {
pub enum DraftSyncStatus {
Saved {
path: String,
typ: String,
typ: UserDraftItemKind,
created_at: chrono::DateTime<chrono::Utc>,
},
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<Box<serde_json::value::RawValue>>,
server_created_at: chrono::DateTime<chrono::Utc>,
@@ -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<Box<serde_json::value::RawValue>>", created_at
r#"SELECT path,
typ as "typ!: UserDraftItemKind",
value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
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<Box<serde_json::value::RawValue>>", created_at
r#"SELECT path,
typ as "typ!: UserDraftItemKind",
value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
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<DB>,
Extension(user_db): Extension<UserDB>,
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<Json<Vec<UserWithDraft>>> {
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<DB>,
Extension(user_db): Extension<UserDB>,
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<GetDraftQuery>,
) -> Result<Json<DraftForUser>> {
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 {
@@ -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. */
+14 -2
View File
@@ -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<UserDraftItemKind, (typeof USER_DRAFT_ITEM_KINDS)[number]> extends never ? true : never
const _: _AssertKindsExhaustive = true
void _
export type UserDraftOptions = {
workspace?: string