mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
* refactor: give home multi-select a reserved gutter and a menu entry Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep checkbox theming and reserve the gutter on non-selectable rows Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: carry every draft with an item when it moves Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: move draft-only items and warn editors when an item moves Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: put the home selection checkbox back in the kind icon slot Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FaEacdxR6M6VDej6C9r39 * feat: animate the home bulk bar and exit selection at zero Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FaEacdxR6M6VDej6C9r39 * fix: keep dialog icon badges round and the panel inside narrow viewports Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FaEacdxR6M6VDej6C9r39 * fix: address review findings on the draft-carry path Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FaEacdxR6M6VDej6C9r39 * fix: keep a staged rename when a move carries the draft Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: restamp only the deployer's own carried draft Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: scope the moved-save restamp to the mover as well Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: read the app move's author from the head version, not the draft's base Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: carry a flow draft's baseline path so deploying it cannot un-move the flow Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: reject unsupported kinds in move_draft, survive NUL-poisoned draft rows Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: skip NUL-poisoned rows in every draft-value rewrite, not just the first Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: report a NUL-poisoned draft on move instead of 500ing Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: name the attempted operation in the NUL rejection message Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: drop dead selection code and comments that outlived their state Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: describe script staleness as head-pinned, which is what the loader does Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: correct the third staleness comment left claiming a stable fork base Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address CI review — auth order, save race, carry failure, path validation Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: gate operators earlier, skip the write tx without lineage, unblock a chained move Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: run the post-write moved re-assert under RLS, not the raw pool Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: pin the moved answer to what the saver can see The post-write re-assert names a path and a username, and nothing at any layer stopped it reading them off a raw pool connection. Swapping the transaction back to `db.begin()` compiles and passes everything else, so the guard has to be a test: a non-admin saving at a path whose item moved into a folder they cannot see gets `saved`, while the admin gets `moved`. Also drops two doc comments still arguing that clearing the write gate at the old path removes the need for an RLS envelope. It does not — the gate resolves the old path and the re-assert asks about the new one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: name the real deploy path and stop restating the RLS constraint `update_path` is not a symbol in this repo; a script move goes through `create_script`. The re-assert's comment re-derived the disclosure argument that already sits on `resolve_moved_to_in`, where a caller would break it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: state the RLS and restamp constraints once each The RLS envelope was argued at three sites in drafts.rs; it now sits only on `resolve_moved_to_in`, whose signature is what a caller would break. The restamp scoping was copy-pasted at all three deploy call sites while already documented in full on `move_drafts_for_path`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: carry both path keys on a move, and grant the draft sequence The upsert now runs as `windmill_user`, so it calls nextval on `draft_id_seq` as that role. The only thing granting that is the ALTER DEFAULT PRIVILEGES in 20250205131523, whose DO block swallows failures — so an instance where it errored would fail every autosave with `permission denied for sequence`. A draft value carries two path keys: the typed one and a mirror the editors keep in step with it while it differs from the row's path. Rewriting only the typed one left the mirror naming the old location, and the loaders prefer the mirror — reopening a moved session script restored the old path and the next save un-did the move. Both keys now follow, in the move endpoint and in the passive carry, under the same tri-state rule. `typed_path_field` answered `draft_path` for every non-script kind, including resources, variables and triggers, which have no such key. It returns `None` for them now, and `move_draft` reads its guard off that mapping so the movable set and the field mapping cannot drift apart. Also documents that `move_drafts_for_path` mutates every owner's row and enforces nothing itself, and parses the draft payload once per save instead of three times. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: pin the two-key move, and stop the down migration breaking instances Revoking the sequence grant would strip a privilege a healthy instance had before this migration ran — the grant it adds is indistinguishable in the catalog from the one ALTER DEFAULT PRIVILEGES gives at creation time — so the down is a comment, matching the other grant-only migrations. The mirror rewrite is spread over three sites that have to agree and fails silently when they don't, so it gets a test: a draft carrying both path keys has both moved, and one carrying neither mirror does not gain one. It reads the value back over HTTP rather than with `sqlx::query!`, which would need an offline cache entry of its own. Also drops twelve `.sqlx` entries this branch added and then superseded, and corrects the doc and openapi text that still described only the typed path being rewritten. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: point the empty down at the grant it is declining to revoke Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: drop the restamp and tri-state; a move relocates the draft row only A deploy that renames an item is a deploy like any other: every draft on the item goes stale, and the stale prompt with its diff is the single mechanism to catch up. move_drafts_for_path now touches only the row's path column, so the value keeps the base version the draft actually forked from, and the "moved" patch carries no version restamp. DraftBaseVersion shrinks to the three per-kind lineage fields. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat: stale prompt links to a diff that names and lets you pick the deployed version The stale-draft prompt gains "See what changed", which opens the diff drawer. The drawer resolves the deployed side by the draft row's own path (not the typed path, which after a rename still names the archived row), labels which version the left pane is, and offers a picker over the item's deployed history for scripts, flows and raw apps. The history endpoints return created_by (and created_at for apps) so each entry can name its deployer. "Restore to deployed" moves to the header actions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test: move_to asserts the response status Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: keep a script draft's base at the version it forked from The script editor seeded the draft's parent_hash from the deployed head on every load, and the next autosave persisted it, so a draft behind the deploy read as up to date after being opened once. The base now comes from the draft when one exists; the head is only used for a fresh checkout or an explicit topHash. Deploy already fetches the live head and confirms on mismatch, so the base is what makes that check meaningful. The webhook "run this version" URL uses the deployed hash rather than the draft's base. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat: store the version a draft forked from in one draft.base column Every kind kept its fork base under a different name and type inside the value: parent_hash (hex) for scripts, version_id for flows, parent_version for apps. draft.base holds it as one text id, derived on save from the value so every writer fills it the same way, backfilled by the migration (rows holding a NUL are skipped, since ->> raises on them). The get-by-path overlay exposes it as draft_base and the drafts list as base; the editors and the compare page read that one field and compare it to the head as text. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat: raw-app drafts carry a fork base, so behind means base != head for them too The raw-app bundle never carried the version it forked from, which left raw apps on the timestamp check that self-heals as you type, and the header's deploy guard read a version prop nothing set, so deploying over a newer version never asked. The route now stamps parent_version into the bundle (the draft's own base when it has one, else the head), the server derives draft.base from it, the stale prompt compares it to the head and links to the diff, and the editor threads it to the header so the deploy guard confirms. A deploy re-pins the base to the version it wrote. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat: refuse a rename onto a path that already holds a draft A draft occupies its path the way a deployed item does: a never-deployed item, or a draft left on an archived script. Renaming onto it would either merge two items or leave the losing row stranded at a path its item has left. The move now refuses with a BadRequest inside the deploy's transaction, so the rename itself fails and the source stays deployed. Every draft on the item then moves; there is no longer a left-behind count to report. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat: save drafts by row id, so an open editor follows its draft through a move A rename carries every draft on the item to the new path. An editor left open across it was still saving by the path it opened on, which the server had to refuse and answer with where the item went (the "moved" handshake and its modal). The draft row has an id: the get-by-path overlay now returns it as draft_id, every later save sends it, and the server writes the row wherever it is and answers with that path. The editor then follows: it flushes what it holds, tells the user, and navigates to the item's new path, where the stale prompt says what changed. The lineage-based move resolvers, the moved status and the moved modal are gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat: the out-of-date prompt names both versions and can take the latest as the new base The prompt now says which version the draft forked from and which is deployed (and by whom), instead of two timestamps, and gains "Take latest, keep my edits": the draft's base moves to the head and its content stays, so the user can acknowledge a newer version without discarding their work. Each route sets its kind's base field on the draft value and persists it; the raw-app bundle carries it already, so setting the state is enough there. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat: two-action out-of-date prompt; taking the latest moves into the diff drawer Four buttons made the prompt hard to read. It keeps "See what changed" and a red "Use latest" (it replaces the draft); closing it is keeping the draft. "Take latest, keep my edits" moves to the diff drawer's header, offered only while the draft is behind, so the user takes the latest with the diff in front of them. Scripts, flows and raw apps pass the action through their diff drawer; the classic app editor has no drawer wired to the prompt and loses it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore: drop the draft_id_seq grant; the draft upsert runs on the raw pool Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a moved draft's path keys follow it, and a refused rename names the draft's owner Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: follow a moved draft on tab close, and deploy a followed flow at its new path Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: write a followed draft by id against the row's own path keys; keep base on assign and clone Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: look up a script's head at its row path, and show flow and app version ids bare Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: session editors save by draft id; raw apps keep a legacy draft's base unknown Also advance the raw-app base on deploy, relocate once per move, drop the hoisted operator check and the unread base on drafts/list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: guard a base-unknown raw-app deploy against the head at load; keep the base in session hydration Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: the server follows a moved draft through a move record, not client-sent row ids A move writes old path -> new path (per workspace and kind, per owner for a draft-only move) in its transaction; a draft save or discard addressed to a path the caller has no draft at resolves through it and keeps the moved draft's path keys. Creating an item at a path drops the records leaving it. Every writer (edit routes, sessions, chat, CLI, the tab-close flush) follows without passing an id, so the id plumbing is gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: session loaders keep a draft's base, and a failed relocation flush stays put Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a draft-only app move refuses the other app kind; a session keeps an unknown base unknown Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: pin a teammate's carried draft; name the kind that refuses a draft move Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an unknown base stays unknown in every loader, and an owner move extends an item move Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a clone keeps only a base it can resolve; a base-unknown script deploys without a false guard Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a workspace clone sanitizes a NUL-bearing draft instead of copying it unstripped Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: move records follow an account rename and deletion; a legacy draft says why it cannot move Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an owner move extends only the item's own route, not another user's Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a redeploy ends a route off its path, take-latest persists on raw apps, stale picker loads are dropped Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a poisoned draft's path keys follow a move, legacy only bypasses routing on a delete, picker loads are generation-guarded Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: count picker load generations, and report a skipped legacy upsert as a conflict Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a legacy discard follows the item's move record too Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a failed version load keeps the picker on what the diff shows; one spelling for a legacy delete Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the picker marks the version on display as head, restore compares the head, relocation follows the last move Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: say so when a version fails to load in the diff picker Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: take latest re-reads the head at click time; type the kept head as prepared diff data Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: taking the latest moves the head each editor knows, not just the base Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: take latest adopts the head the diff shows, and is offered while the drawer sees the draft behind Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a head nobody could name is not behind, so take latest is not offered without one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the flow drawer's head is the version its payload came from, and its callback type says so Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a NUL in a move's summary is dropped, and take latest simply adopts the head it was handed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a loaded raw-app draft keeps its own fork base, and an unknown head is refused Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a routed discard names where it landed, a superseded drawer opening is dropped, and a loaded draft keeps its base in every editor Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a legacy draft occupies its destination, a superseded opening writes nothing, and a loaded flow draft keeps no base it lacks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the drawer owns its opening, a loaded script draft keeps no base it lacks, and a legacy occupant says who can clear it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: taking the diff drawer without a token claims it, and the classic app editor takes one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a retried routed discard still names the destination, and filling the drawer takes the opening too Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a no-op routed discard names the destination only to someone who could write there Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the no-op routed discard gates its answer on reading the destination, and a session draft keeps its unknown base Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: abandoning an opening clears the drawer it still owns Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an app deploy pins only a version it wrote as the next draft's base Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the deploy-override diff takes an opening its editor can hand back Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: pin the version this deploy wrote even when one landed on top, and tighten three comment blocks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a deploy claims only the version it appended to the head it read, and names the head separately Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a deploy always names the head it left behind, and pins a base only when it can claim one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the derived base is read after the sanitizer, and a deploy that claims nothing leaves no base to compare Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the route's lineage follows an in-place deploy, and the raw-app editor's event type carries the head Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: the raw-app deploy comment says what that editor actually does with version Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a group member can be told where their item went, and a deploy names the head's author Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an emptied selection is no shift anchor, and a deploy leaves no draft for the prompt to compare Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: session tabs compare the same base pair, and a consumed draft is not out of date Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a failed anchor read is not a raced deploy, and take latest closes only its own drawer Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an unclaimed deploy always confirms, and the prompt keeps warning a loaded teammate draft Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: the base-unknown confirmation says what it knows, and two comments match the guard Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the other app kind collides whoever owns it, and session tabs get a head to compare Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the cross-kind refusal reads properly, and a session flow keeps its own response's head Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a fresh session checkout takes the head its payload came from, and a deploy keeps the base it pinned Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the move endpoint validates its source path, and two comments say what their branch does Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an unanswered head read confirms rather than assuming the app editor is current Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a deploy is not blocked by the draft a move carried to its destination Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an unread head confirms with the copy for caution, not for an observed deploy Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the move record alone excuses a carried draft at the destination, whoever owns it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: an app deploy answers with the version it wrote, so the editor stops inferring it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: the rename assertion reads the deploy's json answer Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the unread-head warning reads as caution in the deploy drawer too, and the cross-kind refusal names a remedy Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a reused destination retires the routes pointing at it, and draft_base stays out of diffs Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the app head is the tail of app.versions, not the newest timestamp Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: app history lists in deployed order, so the picker numbers it right Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the ordering test's setup sql compiles offline, and the head join names its app Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: kinds that cannot move skip the move lookup, and the move wording needs read Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf: a deploy history comes a page at a time, so the diff drawer opens at once Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a history stays whole unless asked to page, and pages inside the version array Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an asked-for history page is bounded, and a failed one is not the end of the list Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an unasked history is whole again, and an absurd page is empty not an error Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: naming only a page still asks for one, and a stray version stays reachable Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a fork's nul-poisoned draft arrives clean, so its dangling identity repoints too Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a raw app names its deployed version even when the history will not load Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
966 lines
39 KiB
Rust
966 lines
39 KiB
Rust
/*
|
|
* Author: Diego Imbert
|
|
* Copyright: Windmill Labs, Inc 2026
|
|
* 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.
|
|
*/
|
|
|
|
//! Shared types and helpers for the per-user `draft` table. Lives in
|
|
//! `windmill-common` so entity crates can use it without depending on the
|
|
//! top-level `windmill-api` crate. Keep it free of HTTP/axum concerns.
|
|
|
|
// `DraftUserRef` lives in `windmill-types` (where the list-endpoint row
|
|
// structs `ListableScript`/`ListableFlow` declare `Vec<DraftUserRef>` and
|
|
// can't reach `windmill-common` without a cycle). Re-exported here so draft
|
|
// handlers keep a single import path.
|
|
pub use windmill_types::user_drafts::DraftUserRef;
|
|
|
|
use crate::db::DB;
|
|
use crate::error::Result;
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Item kinds a user can have an autosaved draft on. Must stay in lockstep
|
|
/// with the frontend `USER_DRAFT_ITEM_KINDS` and the Postgres `DRAFT_KIND`
|
|
/// enum (adding a kind also needs an `ALTER TYPE ... ADD VALUE` migration).
|
|
/// `snake_case` is the shared wire/DB encoding (HTTP params, JSON, `draft.typ`).
|
|
#[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,
|
|
TriggerAmqp,
|
|
TriggerSqs,
|
|
TriggerGcp,
|
|
TriggerAzure,
|
|
TriggerPoll,
|
|
TriggerCli,
|
|
TriggerNextcloud,
|
|
TriggerGoogle,
|
|
TriggerGithub,
|
|
/// All unsaved scripts of one data pipeline, bundled into a single draft
|
|
/// keyed at the pipeline's folder path. Not a runnable: it has no deployed
|
|
/// backing table and is private to its owner.
|
|
DataPipeline,
|
|
}
|
|
|
|
impl UserDraftItemKind {
|
|
/// The snake_case wire/DB string, for interpolating into dynamically-built
|
|
/// SQL (`?::DRAFT_KIND` binds want a string).
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
UserDraftItemKind::Script => "script",
|
|
UserDraftItemKind::Flow => "flow",
|
|
UserDraftItemKind::App => "app",
|
|
UserDraftItemKind::RawApp => "raw_app",
|
|
UserDraftItemKind::Resource => "resource",
|
|
UserDraftItemKind::Variable => "variable",
|
|
UserDraftItemKind::TriggerSchedule => "trigger_schedule",
|
|
UserDraftItemKind::TriggerWebhook => "trigger_webhook",
|
|
UserDraftItemKind::TriggerDefaultEmail => "trigger_default_email",
|
|
UserDraftItemKind::TriggerEmail => "trigger_email",
|
|
UserDraftItemKind::TriggerHttp => "trigger_http",
|
|
UserDraftItemKind::TriggerWebsocket => "trigger_websocket",
|
|
UserDraftItemKind::TriggerPostgres => "trigger_postgres",
|
|
UserDraftItemKind::TriggerKafka => "trigger_kafka",
|
|
UserDraftItemKind::TriggerNats => "trigger_nats",
|
|
UserDraftItemKind::TriggerMqtt => "trigger_mqtt",
|
|
UserDraftItemKind::TriggerAmqp => "trigger_amqp",
|
|
UserDraftItemKind::TriggerSqs => "trigger_sqs",
|
|
UserDraftItemKind::TriggerGcp => "trigger_gcp",
|
|
UserDraftItemKind::TriggerAzure => "trigger_azure",
|
|
UserDraftItemKind::TriggerPoll => "trigger_poll",
|
|
UserDraftItemKind::TriggerCli => "trigger_cli",
|
|
UserDraftItemKind::TriggerNextcloud => "trigger_nextcloud",
|
|
UserDraftItemKind::TriggerGoogle => "trigger_google",
|
|
UserDraftItemKind::TriggerGithub => "trigger_github",
|
|
UserDraftItemKind::DataPipeline => "data_pipeline",
|
|
}
|
|
}
|
|
|
|
/// Every variant, for code that must enumerate kinds (e.g. generating
|
|
/// the `draft_only` existence SQL).
|
|
pub const ALL: [UserDraftItemKind; 26] = [
|
|
UserDraftItemKind::Script,
|
|
UserDraftItemKind::Flow,
|
|
UserDraftItemKind::App,
|
|
UserDraftItemKind::RawApp,
|
|
UserDraftItemKind::Resource,
|
|
UserDraftItemKind::Variable,
|
|
UserDraftItemKind::TriggerSchedule,
|
|
UserDraftItemKind::TriggerWebhook,
|
|
UserDraftItemKind::TriggerDefaultEmail,
|
|
UserDraftItemKind::TriggerEmail,
|
|
UserDraftItemKind::TriggerHttp,
|
|
UserDraftItemKind::TriggerWebsocket,
|
|
UserDraftItemKind::TriggerPostgres,
|
|
UserDraftItemKind::TriggerKafka,
|
|
UserDraftItemKind::TriggerNats,
|
|
UserDraftItemKind::TriggerMqtt,
|
|
UserDraftItemKind::TriggerAmqp,
|
|
UserDraftItemKind::TriggerSqs,
|
|
UserDraftItemKind::TriggerGcp,
|
|
UserDraftItemKind::TriggerAzure,
|
|
UserDraftItemKind::TriggerPoll,
|
|
UserDraftItemKind::TriggerCli,
|
|
UserDraftItemKind::TriggerNextcloud,
|
|
UserDraftItemKind::TriggerGoogle,
|
|
UserDraftItemKind::TriggerGithub,
|
|
UserDraftItemKind::DataPipeline,
|
|
];
|
|
|
|
/// The deployed table backing this kind, keyed by `(workspace_id, path)`.
|
|
/// SINGLE SOURCE for both the draft access check (which table RLS resolves
|
|
/// item-level `extra_perms` against) and the `draft_only` existence check.
|
|
/// `None` for kinds with no per-path backing table (webhook is a property
|
|
/// of a script/flow row; native triggers are keyed by external_id, not
|
|
/// path) — callers treat that as "no deployed counterpart": `draft_only =
|
|
/// true` and a path-only access check.
|
|
pub fn deployed_table(&self) -> Option<&'static str> {
|
|
use UserDraftItemKind::*;
|
|
match self {
|
|
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"),
|
|
TriggerAmqp => Some("amqp_trigger"),
|
|
TriggerSqs => Some("sqs_trigger"),
|
|
TriggerGcp => Some("gcp_trigger"),
|
|
TriggerAzure => Some("azure_trigger"),
|
|
TriggerEmail | TriggerDefaultEmail => Some("email_trigger"),
|
|
TriggerWebhook | TriggerPoll | TriggerCli | TriggerNextcloud | TriggerGoogle
|
|
| TriggerGithub => None,
|
|
// Keyed at a folder path, not a runnable; access falls back to the
|
|
// path-only (folder write) check.
|
|
DataPipeline => None,
|
|
}
|
|
}
|
|
|
|
/// The `draft.value` key holding the user-typed target path — where a
|
|
/// deploy of this draft would land when the user staged a rename. A script
|
|
/// draft round-trips its own `path`; flows and apps write a separate
|
|
/// `draft_path`, and only when it differs from the row's path.
|
|
///
|
|
/// `None` for every kind with no editor to stage a rename in — a resource,
|
|
/// a variable, a trigger. Their drafts carry no such key, so `Some` is also
|
|
/// the test for whether a kind can be moved at all.
|
|
pub fn typed_path_field(&self) -> Option<&'static str> {
|
|
use UserDraftItemKind::*;
|
|
match self {
|
|
Script => Some("path"),
|
|
Flow | App | RawApp => Some("draft_path"),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The other of the two path keys a draft value can carry. The editors mirror
|
|
/// the typed path into it while it differs from the row's path, so a move that
|
|
/// rewrote only `typed_path_field` would leave the mirror naming the old
|
|
/// location — and the loaders prefer the mirror, which un-does the move.
|
|
pub fn mirror_path_field(&self) -> Option<&'static str> {
|
|
match self.typed_path_field()? {
|
|
"path" => Some("draft_path"),
|
|
_ => Some("path"),
|
|
}
|
|
}
|
|
|
|
/// Whether OTHER users' drafts at a path are visible to a viewer (the
|
|
/// "others are editing" list, owner circles, and the `get_draft_for_user`
|
|
/// View JSON / Fork endpoint). Enabled only for the full-page editor items
|
|
/// which have the cross-user draft UI. Drawer items keep drafts private to
|
|
/// their owner: they have no such UI, and exposing a secret variable draft
|
|
/// would hand out the `$encrypted:` ciphertext, which a viewer could
|
|
/// launder into plaintext via a deploy.
|
|
pub fn shares_drafts_across_users(&self) -> bool {
|
|
use UserDraftItemKind::*;
|
|
matches!(self, Script | Flow | App | RawApp)
|
|
}
|
|
}
|
|
|
|
/// Query-string flag accepted by every "get by path" route that supports
|
|
/// the draft overlay. `#[serde(flatten)]` into a route-specific query struct
|
|
/// when the route has other query fields.
|
|
#[derive(Debug, Deserialize, Default)]
|
|
pub struct WithDraftQuery {
|
|
/// When true, attach the authed user's draft (if any) as a separate
|
|
/// `draft` field. Defaults to false so non-editor callers see the
|
|
/// deployed shape unchanged.
|
|
#[serde(default)]
|
|
pub get_draft: bool,
|
|
}
|
|
|
|
/// One row of `other_drafts_users`: a draft on the same path owned by
|
|
/// someone other than the authed user. `username` is `None` for the legacy
|
|
/// NULL-email row, surfaced in the frontend as a "Legacy draft" entry.
|
|
#[derive(Debug, Serialize)]
|
|
pub struct OtherDraftUser {
|
|
/// `None` represents a legacy workspace-level draft (no owner).
|
|
pub username: Option<String>,
|
|
/// When this user's draft was last saved (the `draft.created_at` upsert
|
|
/// timestamp), surfaced in the fork modal as "Last updated".
|
|
pub draft_saved_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// Response wrapper: the deployed entity untouched plus the authed user's
|
|
/// draft (if any) as a sibling `draft` field, which the frontend pairs to
|
|
/// diff/restore/discard. The deployed and the draft are NEVER merged on the
|
|
/// server — the editor's saved shape can diverge arbitrarily, so any per-kind
|
|
/// translation lives in the frontend loader. `inner` is boxed-erased so a
|
|
/// possibly MB-scale deployed payload serializes in ONE pass (no
|
|
/// `serde_json::Value` round-trip) while keeping the struct non-generic.
|
|
#[derive(Serialize)]
|
|
pub struct WithDraftOverlay {
|
|
/// Deployed payload, flattened to the top level.
|
|
#[serde(flatten)]
|
|
pub inner: Box<dyn erased_serde::Serialize + Send>,
|
|
pub is_draft: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub draft_saved_at: Option<DateTime<Utc>>,
|
|
/// The deployed version the draft forked from (`draft.base`), as text
|
|
/// whatever the kind. The editor compares it to the head it loaded to tell
|
|
/// a draft that is behind. Absent when there is no draft or it was never
|
|
/// forked from a deploy.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub draft_base: Option<String>,
|
|
/// True when no deployed row exists at this path: `inner` is only a
|
|
/// best-effort stand-in synthesized from the draft and only `draft` is
|
|
/// canonical. Frontend uses this to disable "diff/reset vs deployed" and
|
|
/// skip its deployed-shape parsing of `inner`. Omitted when false.
|
|
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
|
pub no_deployed: bool,
|
|
/// The user's saved draft payload (whatever shape the editor wrote).
|
|
/// Present when `get_draft=true` and a draft exists.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub draft: Option<serde_json::Value>,
|
|
/// Other users with a draft on the same path (excludes the authed user).
|
|
/// Empty list is omitted to keep the common-case response lean.
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
pub other_drafts_users: Vec<OtherDraftUser>,
|
|
}
|
|
|
|
/// List every other user (and the legacy NULL-email row, if any) with a
|
|
/// draft at `(workspace, kind, path)`. Returns usernames only — emails never
|
|
/// leave the server. LEFT JOIN against `usr` so an orphaned draft (user
|
|
/// removed from the workspace) still surfaces with `username = None`. The
|
|
/// authed user is excluded via `email <> authed_email`; the legacy row
|
|
/// matches because `email IS NULL` fails that comparison.
|
|
async fn fetch_other_drafts_users(
|
|
db: &DB,
|
|
w_id: &str,
|
|
authed_email: &str,
|
|
kind: UserDraftItemKind,
|
|
path: &str,
|
|
) -> Result<Vec<OtherDraftUser>> {
|
|
// A superadmin authoring in a workspace they are not a member of has no `usr`
|
|
// row: fall back to their instance-derived username (`password.username`), or
|
|
// their email when derivation is disabled. Else a real teammate's draft renders
|
|
// as a phantom "Legacy draft". The genuine NULL-email legacy row keeps
|
|
// `username = None` (no `usr`/`password` match and `d.email` is NULL), which is
|
|
// why an owner that resolves to no name at all — an external JWT's subject has
|
|
// neither row — is dropped instead: `None` is taken to mean "legacy" downstream.
|
|
let rows = sqlx::query_as!(
|
|
OtherDraftUser,
|
|
r#"SELECT COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END) as "username?",
|
|
d.created_at as "draft_saved_at!"
|
|
FROM draft d
|
|
LEFT JOIN usr u
|
|
ON u.workspace_id = d.workspace_id
|
|
AND u.email = d.email
|
|
LEFT JOIN password p
|
|
ON p.email = d.email
|
|
AND p.super_admin = true
|
|
WHERE d.workspace_id = $1
|
|
AND d.path = $2
|
|
AND d.typ = $3
|
|
AND (d.email IS NULL OR d.email <> $4)
|
|
AND (d.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)
|
|
ORDER BY d.email NULLS LAST"#,
|
|
w_id,
|
|
path,
|
|
kind as UserDraftItemKind,
|
|
authed_email,
|
|
)
|
|
.fetch_all(db)
|
|
.await?;
|
|
Ok(rows)
|
|
}
|
|
|
|
/// If `get_draft` is true AND the authed user has a draft for
|
|
/// `(workspace, kind, path)`, attach it as `draft`. `deployed` is always
|
|
/// serialized into `inner` untouched.
|
|
pub async fn maybe_overlay_draft<T>(
|
|
db: &DB,
|
|
w_id: &str,
|
|
email: &str,
|
|
kind: UserDraftItemKind,
|
|
path: &str,
|
|
get_draft: bool,
|
|
deployed: T,
|
|
) -> Result<WithDraftOverlay>
|
|
where
|
|
T: serde::Serialize + Send + 'static,
|
|
{
|
|
// Non-editor callers (worker/CLI reads of possibly MB-scale flows/apps)
|
|
// pass `get_draft = false` and render no overlay, so skip the `usr` join.
|
|
if !get_draft {
|
|
return Ok(WithDraftOverlay {
|
|
inner: Box::new(deployed),
|
|
is_draft: false,
|
|
draft_saved_at: None,
|
|
draft_base: None,
|
|
no_deployed: false,
|
|
draft: None,
|
|
other_drafts_users: Vec::new(),
|
|
});
|
|
}
|
|
|
|
// Independent of the authed user's OWN draft: reset-to-deployed reloads
|
|
// still need to know who else is editing this path. Only the cross-user
|
|
// kinds surface it (see `shares_drafts_across_users`).
|
|
let other_drafts_users = if kind.shares_drafts_across_users() {
|
|
fetch_other_drafts_users(db, w_id, email, kind, path).await?
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
// Prefer the user's OWN per-user draft, falling back to the legacy
|
|
// NULL-email workspace draft. `NULLS LAST` + `LIMIT 1` drops the legacy
|
|
// row when an owned one exists.
|
|
let row = sqlx::query!(
|
|
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
|
created_at, base
|
|
FROM draft
|
|
WHERE workspace_id = $1
|
|
AND (email = $2 OR email IS NULL)
|
|
AND path = $3
|
|
AND typ = $4
|
|
ORDER BY email NULLS LAST
|
|
LIMIT 1"#,
|
|
w_id,
|
|
email,
|
|
path,
|
|
kind as UserDraftItemKind,
|
|
)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
|
|
let Some(row) = row else {
|
|
return Ok(WithDraftOverlay {
|
|
inner: Box::new(deployed),
|
|
is_draft: false,
|
|
draft_saved_at: None,
|
|
draft_base: None,
|
|
no_deployed: false,
|
|
draft: None,
|
|
other_drafts_users,
|
|
});
|
|
};
|
|
|
|
let draft_json: serde_json::Value = serde_json::from_str(row.value.0.get())?;
|
|
|
|
Ok(WithDraftOverlay {
|
|
inner: Box::new(deployed),
|
|
is_draft: true,
|
|
draft_saved_at: Some(row.created_at),
|
|
draft_base: row.base,
|
|
no_deployed: false,
|
|
draft: Some(draft_json),
|
|
other_drafts_users,
|
|
})
|
|
}
|
|
|
|
/// One row of a "draft-only" list synthesis: a draft at `path` with no
|
|
/// deployed counterpart. `value` is the editor's saved JSON (each handler
|
|
/// maps it into its own `Listable*` shape).
|
|
#[derive(sqlx::FromRow)]
|
|
pub struct DraftOnlyListRow {
|
|
pub path: String,
|
|
pub value: sqlx::types::Json<Box<serde_json::value::RawValue>>,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// Fetch the authed user's draft rows at paths with NO deployed counterpart,
|
|
/// for synthesizing draft-only entries into a list response. Absence is
|
|
/// checked against `kind.deployed_table()` (the shared single source).
|
|
/// Returns empty for kinds with no path-keyed table. Callers keep their own
|
|
/// gating (`include_draft_only`, page 0, no filters) and row mapping.
|
|
pub async fn fetch_draft_only_list_rows(
|
|
db: &DB,
|
|
w_id: &str,
|
|
email: &str,
|
|
kind: UserDraftItemKind,
|
|
) -> Result<Vec<DraftOnlyListRow>> {
|
|
let Some(table) = kind.deployed_table() else {
|
|
return Ok(Vec::new());
|
|
};
|
|
// `table` is from the closed `deployed_table()` enum, never user input.
|
|
// `(email = $3 OR email IS NULL)` surfaces the user's own draft-only rows
|
|
// AND the legacy NULL-email rows; `DISTINCT ON (path)` with `email IS NULL`
|
|
// last collapses a path that has both to the owned row.
|
|
let sql = format!(
|
|
"SELECT DISTINCT ON (path) path, value, created_at FROM draft \
|
|
WHERE workspace_id = $1 AND typ = $2::text::DRAFT_KIND \
|
|
AND (email = $3 OR email IS NULL) \
|
|
AND NOT EXISTS (SELECT 1 FROM {table} t \
|
|
WHERE t.workspace_id = draft.workspace_id AND t.path = draft.path) \
|
|
ORDER BY path, (email IS NULL)"
|
|
);
|
|
let rows = sqlx::query_as::<_, DraftOnlyListRow>(&sql)
|
|
.bind(w_id)
|
|
.bind(kind.as_str())
|
|
.bind(email)
|
|
.fetch_all(db)
|
|
.await?;
|
|
Ok(rows)
|
|
}
|
|
|
|
/// Delete the caller's OWN draft at a path with no deployed row, for the DELETE
|
|
/// route of a kind whose list synthesizes such rows via
|
|
/// `fetch_draft_only_list_rows`. The `NOT EXISTS` leaves a deployed row's draft
|
|
/// alone, so a route may call this on its not-found branch whatever the reason
|
|
/// for the miss. `Ok(false)` means nothing matched: the caller reports its own error.
|
|
///
|
|
/// Takes no permission check and callers must not add one: an email-scoped row
|
|
/// belongs to the caller, who can always discard it, as `update_draft`'s
|
|
/// own-discard does. Legacy (`email IS NULL`) rows are owned by nobody and keep
|
|
/// their write gate, so discarding one stays on the `update_draft` route.
|
|
pub async fn delete_draft_only_for_path(
|
|
db: &DB,
|
|
w_id: &str,
|
|
kind: UserDraftItemKind,
|
|
path: &str,
|
|
email: &str,
|
|
) -> Result<bool> {
|
|
let Some(table) = kind.deployed_table() else {
|
|
return Ok(false);
|
|
};
|
|
// `table` is from the closed `deployed_table()` enum, never user input.
|
|
let sql = format!(
|
|
"DELETE FROM draft \
|
|
WHERE workspace_id = $1 AND typ = $2::text::DRAFT_KIND AND path = $3 \
|
|
AND email = $4 \
|
|
AND NOT EXISTS (SELECT 1 FROM {table} t \
|
|
WHERE t.workspace_id = draft.workspace_id AND t.path = draft.path)"
|
|
);
|
|
let deleted = sqlx::query(&sql)
|
|
.bind(w_id)
|
|
.bind(kind.as_str())
|
|
.bind(path)
|
|
.bind(email)
|
|
.execute(db)
|
|
.await?
|
|
.rows_affected();
|
|
Ok(deleted > 0)
|
|
}
|
|
|
|
/// The get-by-path draft choreography, shared by every entity's "get by path"
|
|
/// route. Given the deployed entity as an `Option` (caller maps its own "not
|
|
/// found" to `None`):
|
|
/// - `Some(deployed)` → overlay the authed user's draft (if `get_draft`).
|
|
/// - `None` + `get_draft` → draft-only response (`no_deployed = true`) when
|
|
/// a draft exists, else the caller's 404 via `not_found`.
|
|
/// - `None` without `get_draft` → the caller's 404.
|
|
pub async fn overlay_or_draft_only<T: serde::Serialize + Send + 'static>(
|
|
db: &DB,
|
|
w_id: &str,
|
|
email: &str,
|
|
kind: UserDraftItemKind,
|
|
path: &str,
|
|
get_draft: bool,
|
|
deployed: Option<T>,
|
|
not_found: impl FnOnce() -> crate::error::Error,
|
|
) -> Result<WithDraftOverlay> {
|
|
match deployed {
|
|
Some(deployed) => {
|
|
maybe_overlay_draft(db, w_id, email, kind, path, get_draft, deployed).await
|
|
}
|
|
None if get_draft => fetch_draft_only(db, w_id, email, kind, path)
|
|
.await?
|
|
.ok_or_else(not_found),
|
|
None => Err(not_found()),
|
|
}
|
|
}
|
|
|
|
/// Delete the drafts an address owns, and the move records routing saves to them,
|
|
/// across every workspace.
|
|
///
|
|
/// `draft.email` carries no foreign key to `password`: a draft's owner is any principal the
|
|
/// instance authenticates, and an external JWT's subject never has a `password` row. Deleting an
|
|
/// account is therefore what has to delete its drafts — a delete path that skips this leaves them
|
|
/// behind forever, addressed to someone who no longer exists. Call it in the same transaction as
|
|
/// the account removal.
|
|
///
|
|
/// No authorization of its own: it acts instance-wide on whatever address it is handed, so the
|
|
/// caller must already have authorized removing that account (superadmin, the account's own
|
|
/// holder, or SCIM).
|
|
pub async fn delete_drafts_of_email(conn: &mut sqlx::PgConnection, email: &str) -> Result<()> {
|
|
sqlx::query!("DELETE FROM draft WHERE email = $1", email)
|
|
.execute(&mut *conn)
|
|
.await?;
|
|
// The records routing saves to those drafts go with them: one left behind would send
|
|
// a later holder of the address to a draft that no longer exists.
|
|
sqlx::query!("DELETE FROM draft_move WHERE email = $1", email)
|
|
.execute(&mut *conn)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Move the drafts an address owns onto its new address, for the same reason
|
|
/// [`delete_drafts_of_email`] exists: no foreign key follows the rename, so drafts left behind are
|
|
/// stranded on an address that no longer authenticates. Same authorization contract, for a rename.
|
|
///
|
|
/// The two addresses may each already hold a draft of the same item, since the destination can
|
|
/// belong to a principal with no account and so is not covered by the caller's "address is free"
|
|
/// check. `draft_pkey_with_user` admits only one, so the moving account's wins — which is also why
|
|
/// a rename onto the same address returns early: every row would collide with itself and be
|
|
/// cleared. Callers need not compare first (an IdP re-sending an unchanged `userName` does not).
|
|
pub async fn rename_drafts_of_email(
|
|
conn: &mut sqlx::PgConnection,
|
|
old_email: &str,
|
|
new_email: &str,
|
|
) -> Result<()> {
|
|
if old_email == new_email {
|
|
return Ok(());
|
|
}
|
|
sqlx::query!(
|
|
"DELETE FROM draft dest
|
|
WHERE dest.email = $1
|
|
AND EXISTS (SELECT 1 FROM draft src
|
|
WHERE src.email = $2
|
|
AND src.workspace_id = dest.workspace_id
|
|
AND src.path = dest.path
|
|
AND src.typ = dest.typ)",
|
|
new_email,
|
|
old_email
|
|
)
|
|
.execute(&mut *conn)
|
|
.await?;
|
|
sqlx::query!(
|
|
"UPDATE draft SET email = $1 WHERE email = $2",
|
|
new_email,
|
|
old_email
|
|
)
|
|
.execute(&mut *conn)
|
|
.await?;
|
|
// The records that route saves to those drafts follow the same address, or a save
|
|
// still addressed to a path the account moved away from would start a second draft
|
|
// there. The moving account's record for a path wins, as its draft just did.
|
|
sqlx::query!(
|
|
"DELETE FROM draft_move dest
|
|
WHERE dest.email = $1
|
|
AND EXISTS (SELECT 1 FROM draft_move src
|
|
WHERE src.email = $2
|
|
AND src.workspace_id = dest.workspace_id
|
|
AND src.old_path = dest.old_path
|
|
AND src.typ = dest.typ)",
|
|
new_email,
|
|
old_email
|
|
)
|
|
.execute(&mut *conn)
|
|
.await?;
|
|
sqlx::query!(
|
|
"UPDATE draft_move SET email = $1 WHERE email = $2",
|
|
new_email,
|
|
old_email
|
|
)
|
|
.execute(&mut *conn)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete EVERY user's draft (and the legacy NULL-email row) at a path+kind.
|
|
/// Use when the item is DELETED outright: it's gone for everyone, so leaving
|
|
/// teammates' drafts behind would orphan them forever. Discarding one's OWN
|
|
/// draft while the item lives on goes through `update_draft` with `value: null`.
|
|
/// Idempotent on the no-draft case.
|
|
pub async fn delete_all_drafts_for_path(
|
|
db: &DB,
|
|
w_id: &str,
|
|
kind: UserDraftItemKind,
|
|
path: &str,
|
|
) -> Result<()> {
|
|
sqlx::query!(
|
|
r#"DELETE FROM draft
|
|
WHERE workspace_id = $1
|
|
AND path = $2
|
|
AND typ = $3"#,
|
|
w_id,
|
|
path,
|
|
kind as UserDraftItemKind,
|
|
)
|
|
.execute(db)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Discard the deploying user's OWN draft (plus the legacy NULL-email row)
|
|
/// for a path+kind, leaving teammates' drafts intact. Use on RENAME: the
|
|
/// item moved, so the draft at the old path is orphaned (no FK to cascade).
|
|
/// Teammates keep theirs and get the StaleDraftModal on their next reload.
|
|
/// Idempotent on the no-draft case.
|
|
pub async fn delete_own_draft_for_path(
|
|
db: &DB,
|
|
w_id: &str,
|
|
kind: UserDraftItemKind,
|
|
path: &str,
|
|
email: &str,
|
|
) -> Result<()> {
|
|
sqlx::query!(
|
|
r#"DELETE FROM draft
|
|
WHERE workspace_id = $1
|
|
AND path = $2
|
|
AND typ = $3
|
|
AND (email = $4 OR email IS NULL)"#,
|
|
w_id,
|
|
path,
|
|
kind as UserDraftItemKind,
|
|
email,
|
|
)
|
|
.execute(db)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Carry every draft at `old_path` over to `new_path` when an item MOVES
|
|
/// (rename or relocation). A draft is bound to its item by nothing but the path
|
|
/// string, so without this a move detaches every draft on the item. No owner
|
|
/// filter: teammates' rows and the legacy NULL-email row follow too.
|
|
///
|
|
/// **The caller must have authorized the underlying item move first.** This rewrites
|
|
/// rows the caller has no permission on and enforces nothing itself, taking both paths
|
|
/// on trust: it is safe only inside a deploy that has already cleared them, and is a
|
|
/// cross-user write with no gate anywhere else.
|
|
///
|
|
/// The value keeps its base version, so every carried draft reads as behind the head the
|
|
/// move minted. Of its two path keys, one still naming `old_path` follows the row (the
|
|
/// editors write the item's own path there on every save, so it is the item's path and
|
|
/// not a staged rename); any other value is a rename the user staged, and is kept.
|
|
///
|
|
/// A draft already at `new_path` occupies it the way a deployed item does, so the move is
|
|
/// refused with `BadRequest` inside the deploy's transaction, refusing the rename itself:
|
|
/// moving onto it would merge two items or strand the row that lost. A row a `draft_move`
|
|
/// record already brought there from `old_path` is this rename's own draft and does not
|
|
/// count: deploying it would otherwise be refused against itself, with no way out.
|
|
pub async fn move_drafts_for_path(
|
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
|
w_id: &str,
|
|
kinds: &[UserDraftItemKind],
|
|
old_path: &str,
|
|
new_path: &str,
|
|
) -> Result<()> {
|
|
let typs = kinds.iter().map(|k| k.as_str()).collect::<Vec<_>>();
|
|
// Named by workspace username, as the editors name other users' drafts: the
|
|
// caller is often not the owner, and cannot clear a draft they cannot find.
|
|
let owners = sqlx::query!(
|
|
r#"SELECT d.email IS NULL as "legacy!", COALESCE(u.username, p.username) as username
|
|
FROM draft d
|
|
LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email
|
|
LEFT JOIN password p ON p.email = d.email AND p.super_admin = true
|
|
WHERE d.workspace_id = $1 AND d.path = $2 AND d.typ::text = ANY($3::text[])
|
|
-- A row a move already brought here from the very path being renamed is this
|
|
-- rename's own draft, not an item in its way: the record says which owners
|
|
-- it moved (all of them for an item move, one for a draft-only move). Any
|
|
-- other row is a second item and still collides.
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM draft_move m
|
|
WHERE m.workspace_id = $1 AND m.typ::text = ANY($3::text[])
|
|
AND m.old_path = $4 AND m.new_path = $2
|
|
AND (m.email IS NULL OR m.email IS NOT DISTINCT FROM d.email)
|
|
)
|
|
ORDER BY 2"#,
|
|
w_id,
|
|
new_path,
|
|
&typs as &[&str],
|
|
old_path,
|
|
)
|
|
.fetch_all(&mut **tx)
|
|
.await?;
|
|
if !owners.is_empty() {
|
|
let names = owners
|
|
.into_iter()
|
|
.map(|o| match (o.username, o.legacy) {
|
|
(Some(name), _) => name,
|
|
(None, true) => "a legacy workspace draft".to_string(),
|
|
(None, false) => "another user".to_string(),
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
return Err(crate::error::Error::BadRequest(format!(
|
|
"'{new_path}' already has a draft on it ({names}) — it must be moved or discarded first"
|
|
)));
|
|
}
|
|
// `draft.value` is `json`, so a NUL escape left by a pre-sanitizer write makes
|
|
// `to_jsonb` raise 22P05. `clean` is `strip_json_nul`'s parity rule in SQL (escaped
|
|
// backslashes parked on chr(1), which a `json` value's text cannot hold, so nothing
|
|
// collides with it; chr(92) spells the backslash so no escape sequence reaches this
|
|
// file). A row whose keys need re-pointing is therefore rewritten clean rather than
|
|
// left naming the old path, and one that needs nothing keeps its value byte for byte.
|
|
sqlx::query!(
|
|
r#"UPDATE draft AS d
|
|
SET path = $3::text,
|
|
value = (
|
|
SELECT CASE
|
|
WHEN s.clean -> 'path' = to_jsonb($2::text)
|
|
OR s.clean -> 'draft_path' = to_jsonb($2::text)
|
|
THEN to_json(
|
|
s.clean
|
|
|| CASE WHEN s.clean -> 'path' = to_jsonb($2::text)
|
|
THEN jsonb_build_object('path', $3::text)
|
|
ELSE '{}'::jsonb END
|
|
|| CASE WHEN s.clean -> 'draft_path' = to_jsonb($2::text)
|
|
THEN jsonb_build_object('draft_path', $3::text)
|
|
ELSE '{}'::jsonb END
|
|
)
|
|
ELSE d.value
|
|
END
|
|
FROM (SELECT replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)),
|
|
chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean) s
|
|
)
|
|
WHERE d.workspace_id = $1
|
|
AND d.path = $2::text
|
|
AND d.typ::text = ANY($4::text[])"#,
|
|
w_id,
|
|
old_path,
|
|
new_path,
|
|
&typs as &[&str],
|
|
)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
record_draft_move(tx, w_id, kinds, old_path, new_path, None).await
|
|
}
|
|
|
|
/// Record that the drafts at `old_path` now live at `new_path`, so a draft save still
|
|
/// addressed to `old_path` lands on them (see `update_draft`). `email` scopes the
|
|
/// record to one user's draft-only move; `None` is a deployed item's move, for everyone.
|
|
///
|
|
/// Kept to one hop: records pointing at `old_path` are re-pointed (an owner's move
|
|
/// leaves the item's own record alone and copies it into its own scope instead), and
|
|
/// records leaving either path are replaced, since `new_path` now holds the item.
|
|
///
|
|
/// **The caller must have authorized the move first.** A record routes every later
|
|
/// draft write at `old_path` (any owner's, for an item move), and enforces nothing
|
|
/// itself.
|
|
///
|
|
/// A record outlives the editors that need it: it ends when a later move touches either
|
|
/// path or an item is deployed at `old_path`, so a save meaning to start a NEW draft at a
|
|
/// vacated path would be routed instead. Nothing does that today: every surface parks a
|
|
/// new item at a minted `u/<user>/draft_<uuid>` key (`mintDraftPath.ts`).
|
|
pub async fn record_draft_move(
|
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
|
w_id: &str,
|
|
kinds: &[UserDraftItemKind],
|
|
old_path: &str,
|
|
new_path: &str,
|
|
email: Option<&str>,
|
|
) -> Result<()> {
|
|
let typs = kinds.iter().map(|k| k.as_str()).collect::<Vec<_>>();
|
|
sqlx::query!(
|
|
"DELETE FROM draft_move
|
|
WHERE workspace_id = $1 AND typ::text = ANY($2::text[])
|
|
AND old_path IN ($3, $4) AND ($5::text IS NULL OR email = $5)",
|
|
w_id,
|
|
&typs as &[&str],
|
|
old_path,
|
|
new_path,
|
|
email,
|
|
)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
// Routes that ended at the destination before this move describe drafts that were
|
|
// carried there for an item this one is replacing: left alive, a save addressed to
|
|
// the start of that chain would land on this move's draft instead.
|
|
sqlx::query!(
|
|
"DELETE FROM draft_move
|
|
WHERE workspace_id = $1 AND typ::text = ANY($2::text[])
|
|
AND new_path = $4 AND old_path <> $3 AND ($5::text IS NULL OR email = $5)",
|
|
w_id,
|
|
&typs as &[&str],
|
|
old_path,
|
|
new_path,
|
|
email,
|
|
)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
sqlx::query!(
|
|
"UPDATE draft_move SET new_path = $4
|
|
WHERE workspace_id = $1 AND typ::text = ANY($2::text[])
|
|
AND new_path = $3 AND ($5::text IS NULL OR email = $5)",
|
|
w_id,
|
|
&typs as &[&str],
|
|
old_path,
|
|
new_path,
|
|
email,
|
|
)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
// An owner's move must not re-point what everyone else follows, so the item's own
|
|
// records ending at `old_path` are copied into this scope: a save addressed to the
|
|
// start of that chain still reaches this destination in one hop. Only those: another
|
|
// user's owner-scoped record routes their own draft-only item, which this move has
|
|
// nothing to do with.
|
|
if email.is_some() {
|
|
sqlx::query!(
|
|
"INSERT INTO draft_move (workspace_id, typ, old_path, new_path, email)
|
|
SELECT m.workspace_id, m.typ, m.old_path, $4, $5::text
|
|
FROM draft_move m
|
|
WHERE m.workspace_id = $1 AND m.typ::text = ANY($2::text[])
|
|
AND m.new_path = $3 AND m.email IS NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM draft_move o
|
|
WHERE o.workspace_id = m.workspace_id AND o.typ = m.typ
|
|
AND o.old_path = m.old_path AND o.email = $5::text
|
|
)",
|
|
w_id,
|
|
&typs as &[&str],
|
|
old_path,
|
|
new_path,
|
|
email,
|
|
)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
}
|
|
sqlx::query!(
|
|
"INSERT INTO draft_move (workspace_id, typ, old_path, new_path, email)
|
|
SELECT $1, t::draft_kind, $3, $4, $5 FROM unnest($2::text[]) t",
|
|
w_id,
|
|
&typs as &[&str],
|
|
old_path,
|
|
new_path,
|
|
email,
|
|
)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Drop the move records at `path`: an item was just created there, so saves addressed to
|
|
/// it are its own (records leaving `path`). A deploy that is not a rename also drops the
|
|
/// records arriving, which point at an item that no longer owns the path; `keep_from`,
|
|
/// the path a rename came from, suppresses that, since the chain ending here is the one
|
|
/// this very deploy just wrote.
|
|
///
|
|
/// **The caller must have authorized the deploy that created the item first.**
|
|
/// Dropping a record sends later draft writes at `path` back to `path`.
|
|
pub async fn clear_draft_moves_from(
|
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
|
w_id: &str,
|
|
kinds: &[UserDraftItemKind],
|
|
path: &str,
|
|
keep_from: Option<&str>,
|
|
) -> Result<()> {
|
|
let typs = kinds.iter().map(|k| k.as_str()).collect::<Vec<_>>();
|
|
sqlx::query!(
|
|
"DELETE FROM draft_move WHERE workspace_id = $1 AND typ::text = ANY($2::text[])
|
|
AND (old_path = $3 OR (new_path = $3 AND $4::text IS NULL))",
|
|
w_id,
|
|
&typs as &[&str],
|
|
path,
|
|
keep_from,
|
|
)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Fetch the authed user's draft as a standalone payload, for "get by path"
|
|
/// routes when no deployed row exists but a draft might. Returns it as a
|
|
/// `WithDraftOverlay` with `inner` and `draft` both set to the same JSON and
|
|
/// `no_deployed = true`. Callers must have established no deployed row exists;
|
|
/// `Ok(None)` when there's also no draft (caller should 404).
|
|
///
|
|
/// The draft JSON is expected to be an object (so `serde(flatten)` on `inner`
|
|
/// works); a non-object draft renders with no fields flattened.
|
|
pub async fn fetch_draft_only(
|
|
db: &DB,
|
|
w_id: &str,
|
|
email: &str,
|
|
kind: UserDraftItemKind,
|
|
path: &str,
|
|
) -> Result<Option<WithDraftOverlay>> {
|
|
// Own draft first, legacy NULL-email row as fallback (see `maybe_overlay_draft`).
|
|
let row = sqlx::query!(
|
|
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
|
created_at, base
|
|
FROM draft
|
|
WHERE workspace_id = $1
|
|
AND (email = $2 OR email IS NULL)
|
|
AND path = $3
|
|
AND typ = $4
|
|
ORDER BY email NULLS LAST
|
|
LIMIT 1"#,
|
|
w_id,
|
|
email,
|
|
path,
|
|
kind as UserDraftItemKind,
|
|
)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
|
|
let Some(row) = row else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let draft_json: serde_json::Value = serde_json::from_str(row.value.0.get())?;
|
|
let other_drafts_users = if kind.shares_drafts_across_users() {
|
|
fetch_other_drafts_users(db, w_id, email, kind, path).await?
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
Ok(Some(WithDraftOverlay {
|
|
// Best-effort stand-in for the missing deployed — same JSON as `draft`.
|
|
inner: Box::new(draft_json.clone()),
|
|
is_draft: true,
|
|
draft_saved_at: Some(row.created_at),
|
|
draft_base: row.base,
|
|
no_deployed: true,
|
|
draft: Some(draft_json),
|
|
other_drafts_users,
|
|
}))
|
|
}
|
|
|
|
/// Marker prefix for draft secret values encrypted at rest with the workspace
|
|
/// crypt key (`build_crypt`). Written by `update_draft` for secret variables;
|
|
/// resolved back to plaintext by the variable deploy endpoints.
|
|
pub const ENCRYPTED_DRAFT_PREFIX: &str = "$encrypted:";
|
|
|
|
fn draft_decrypt_error() -> crate::error::Error {
|
|
crate::error::Error::BadRequest(
|
|
"An encrypted draft secret could not be decrypted (the workspace encryption key may \
|
|
have changed since the draft was saved). Reset the field and re-enter the secret."
|
|
.to_string(),
|
|
)
|
|
}
|
|
|
|
/// Decrypt a `$encrypted:`-marked draft value back to plaintext with the
|
|
/// workspace crypt key. Fails with a user-facing 400 when it doesn't decrypt
|
|
/// (e.g. the workspace key was rotated after the draft save).
|
|
pub async fn decrypt_draft_secret_value(db: &DB, w_id: &str, value: &str) -> Result<String> {
|
|
let encrypted = value.strip_prefix(ENCRYPTED_DRAFT_PREFIX).unwrap_or(value);
|
|
let mc = crate::variables::build_crypt(db, w_id).await?;
|
|
crate::variables::decrypt(&mc, encrypted.to_string()).map_err(|_| draft_decrypt_error())
|
|
}
|