mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-06 16:02:19 +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>
800 lines
34 KiB
TypeScript
800 lines
34 KiB
TypeScript
/**
|
|
* Draft deploy/discard orchestration for the compare page's "draft" mode.
|
|
*
|
|
* Drafts only exist for scripts, flows and apps (the `draft_type` enum). A draft
|
|
* is the editor's serialized state stored in the `draft` table; deploying it is
|
|
* the same create/update call the editor makes on "Deploy", which auto-deletes
|
|
* the matching draft server-side (unless `skip_draft_deletion`) — so we never
|
|
* call `deleteDraft` after a successful deploy. The lock/dependency job runs
|
|
* async, exactly as in the editor.
|
|
*
|
|
* Discarding branches on `draft_only`: a `draft_only` item exists only as a
|
|
* draft, so discarding deletes the whole item (mirrors `common/table/*Row.svelte`);
|
|
* a draft on an already-deployed item just deletes the draft row.
|
|
*/
|
|
import { get, writable } from 'svelte/store'
|
|
import {
|
|
DraftService,
|
|
ScriptService,
|
|
FlowService,
|
|
AppService,
|
|
VariableService,
|
|
ResourceService,
|
|
ScheduleService,
|
|
HttpTriggerService,
|
|
WebsocketTriggerService,
|
|
PostgresTriggerService,
|
|
KafkaTriggerService,
|
|
NatsTriggerService,
|
|
MqttTriggerService,
|
|
AmqpTriggerService,
|
|
SqsTriggerService,
|
|
GcpTriggerService,
|
|
AzureTriggerService,
|
|
EmailTriggerService,
|
|
type UserDraftItemKind
|
|
} from '$lib/gen'
|
|
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
|
import type { DeployResult } from '$lib/utils_workspace_deploy'
|
|
import { TRIGGER_RUNTIME_IGNORE } from '$lib/utils_deployable'
|
|
import { deployRawAppDraft } from '$lib/rawAppDeploy'
|
|
import { canonicalRawAppDiffValue } from '$lib/components/raw_apps/utils'
|
|
import { classicAppDraftParts } from '$lib/appDiffSides'
|
|
import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
|
|
import { invalidateWorkspaceComparison } from '$lib/workspaceComparison'
|
|
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
|
|
import { userStore } from '$lib/stores'
|
|
import { deployTriggers, type Trigger } from '$lib/components/triggers/utils'
|
|
import { saveScheduleFromCfg } from '$lib/components/flows/scheduleUtils'
|
|
import { saveHttpRouteFromCfg } from '$lib/components/triggers/http/utils'
|
|
import { saveWebsocketTriggerFromCfg } from '$lib/components/triggers/websocket/utils'
|
|
import { savePostgresTriggerFromCfg } from '$lib/components/triggers/postgres/utils'
|
|
import { saveKafkaTriggerFromCfg } from '$lib/components/triggers/kafka/utils'
|
|
import { saveNatsTriggerFromCfg } from '$lib/components/triggers/nats/utils'
|
|
import { saveMqttTriggerFromCfg } from '$lib/components/triggers/mqtt/utils'
|
|
import { saveAmqpTriggerFromCfg } from '$lib/components/triggers/amqp/utils'
|
|
import { saveSqsTriggerFromCfg } from '$lib/components/triggers/sqs/utils'
|
|
import { saveGcpTriggerFromCfg } from '$lib/components/triggers/gcp/utils'
|
|
import { saveAzureTriggerFromCfg } from '$lib/components/triggers/azure/utils'
|
|
import { saveEmailTriggerFromCfg } from '$lib/components/triggers/email/utils'
|
|
|
|
export type DraftKind = UserDraftItemKind
|
|
|
|
/** Kind → "get by path with draft overlay" call, for kinds whose draft is the
|
|
* editor's flat config (all but script/flow/app, handled below). Feature-gated
|
|
* trigger services 404 when the backend lacks the kind; the caller surfaces it. */
|
|
const OVERLAY_GETTERS: Partial<
|
|
Record<DraftKind, (workspace: string, path: string) => Promise<any>>
|
|
> = {
|
|
variable: (workspace, path) =>
|
|
VariableService.getVariable({ workspace, path, decryptSecret: false, getDraft: true }),
|
|
resource: (workspace, path) => ResourceService.getResource({ workspace, path, getDraft: true }),
|
|
trigger_schedule: (workspace, path) =>
|
|
ScheduleService.getSchedule({ workspace, path, getDraft: true }),
|
|
trigger_http: (workspace, path) =>
|
|
HttpTriggerService.getHttpTrigger({ workspace, path, getDraft: true }),
|
|
trigger_websocket: (workspace, path) =>
|
|
WebsocketTriggerService.getWebsocketTrigger({ workspace, path, getDraft: true }),
|
|
trigger_postgres: (workspace, path) =>
|
|
PostgresTriggerService.getPostgresTrigger({ workspace, path, getDraft: true }),
|
|
trigger_kafka: (workspace, path) =>
|
|
KafkaTriggerService.getKafkaTrigger({ workspace, path, getDraft: true }),
|
|
trigger_nats: (workspace, path) =>
|
|
NatsTriggerService.getNatsTrigger({ workspace, path, getDraft: true }),
|
|
trigger_mqtt: (workspace, path) =>
|
|
MqttTriggerService.getMqttTrigger({ workspace, path, getDraft: true }),
|
|
trigger_amqp: (workspace, path) =>
|
|
AmqpTriggerService.getAmqpTrigger({ workspace, path, getDraft: true }),
|
|
trigger_sqs: (workspace, path) =>
|
|
SqsTriggerService.getSqsTrigger({ workspace, path, getDraft: true }),
|
|
trigger_gcp: (workspace, path) =>
|
|
GcpTriggerService.getGcpTrigger({ workspace, path, getDraft: true }),
|
|
trigger_azure: (workspace, path) =>
|
|
AzureTriggerService.getAzureTrigger({ workspace, path, getDraft: true }),
|
|
trigger_email: (workspace, path) =>
|
|
EmailTriggerService.getEmailTrigger({ workspace, path, getDraft: true })
|
|
}
|
|
|
|
/** Whether `getDraftDiffValues` can produce a diff for this kind at all. The
|
|
* script/flow/app family is handled inline; every other kind needs an overlay
|
|
* getter and throws without one — several trigger kinds have none. */
|
|
export function canDiffDraftKind(kind: DraftKind): boolean {
|
|
return (
|
|
kind === 'script' ||
|
|
kind === 'flow' ||
|
|
kind === 'app' ||
|
|
kind === 'raw_app' ||
|
|
OVERLAY_GETTERS[kind] !== undefined
|
|
)
|
|
}
|
|
|
|
/** Strip the per-user draft-overlay metadata, returning `{deployed, draft}`. */
|
|
function splitOverlay(r: any): {
|
|
deployed: any
|
|
draft: any
|
|
hasDraft: boolean
|
|
noDeployed: boolean
|
|
} {
|
|
const {
|
|
draft,
|
|
is_draft: _i,
|
|
draft_saved_at: _c,
|
|
no_deployed,
|
|
other_drafts_users: _o,
|
|
...deployed
|
|
} = r
|
|
return {
|
|
deployed,
|
|
draft: draft ?? deployed,
|
|
hasDraft: draft != null,
|
|
noDeployed: no_deployed === true
|
|
}
|
|
}
|
|
|
|
export interface DraftDiffValues {
|
|
deployed: unknown
|
|
draft: unknown
|
|
/** False when the overlay carried no draft row (the item's own value was used as the draft side). */
|
|
hasDraft: boolean
|
|
/** True when the item has never been deployed (`draft_only` overlay). */
|
|
noDeployed: boolean
|
|
}
|
|
|
|
// Empty-but-valid "deployed" shapes for draft_only items. A bare `{}` breaks
|
|
// the flow graph diff (needs `value.modules`) and hangs the drawer, so each
|
|
// listed kind gets a minimal shape; unlisted kinds fall back to `{}`.
|
|
const EMPTY_DEPLOYED: Partial<Record<DraftKind, (draft: any) => unknown>> = {
|
|
script: (draft) => ({ content: '', language: draft?.language, schema: {} }),
|
|
flow: () => ({ summary: '', value: { modules: [] }, schema: {} }),
|
|
app: () => ({ summary: '', value: {} })
|
|
}
|
|
|
|
// Server-managed script-row fields, stripped from BOTH sides of a draft diff:
|
|
// never user-edited, they are either identical noise (created_at, workspace_id)
|
|
// or spuriously different (lock is recomputed at deploy). The draft-side
|
|
// pinned-base `parent_hash` is stripped separately, like the flow `version_id`.
|
|
const SCRIPT_ROW_RUNTIME_IGNORE = new Set([
|
|
'workspace_id',
|
|
'hash',
|
|
'parent_hash',
|
|
'parent_hashes',
|
|
'created_at',
|
|
'created_by',
|
|
'archived',
|
|
'deleted',
|
|
'extra_perms',
|
|
'lock',
|
|
'lock_error_logs',
|
|
'starred',
|
|
'has_draft',
|
|
'draft_only',
|
|
'assets',
|
|
'marked'
|
|
])
|
|
|
|
function stripScriptRowRuntime(row: any): Record<string, unknown> {
|
|
if (!row || typeof row !== 'object') return {}
|
|
return Object.fromEntries(Object.entries(row).filter(([k]) => !SCRIPT_ROW_RUNTIME_IGNORE.has(k)))
|
|
}
|
|
|
|
/** Canonicalize a raw draft value onto the same shape `getDraftDiffValues`
|
|
* yields for its draft side, so a value read from an in-memory editor cell
|
|
* diffs cleanly against a deployed side (and compares equal to its own
|
|
* persisted form instead of differing on stripped fields). */
|
|
export function canonicalDraftSideValue(kind: DraftKind, value: unknown): unknown {
|
|
if (kind === 'script') return stripScriptRowRuntime(value)
|
|
if (kind === 'raw_app') return canonicalRawAppDiffValue((value ?? {}) as Record<string, any>)
|
|
if (kind === 'app') {
|
|
const parts = classicAppDraftParts(value)
|
|
return { summary: parts.summary ?? '', value: parts.value }
|
|
}
|
|
if (kind === 'flow' && value !== null && typeof value === 'object') {
|
|
const { version_id: _v, ...rest } = value as Record<string, unknown>
|
|
return rest
|
|
}
|
|
// Drawer kinds (variables/resources/schedules/triggers): the editor-state
|
|
// shape diverges from the backend row — same canonicalization the overlay
|
|
// diff applies.
|
|
return canonicalizeDraftDiffValue(kind, value, true)
|
|
}
|
|
|
|
// Schedule & trigger rows drop the same runtime/server-managed fields as the
|
|
// fork/compare path so the diff shows only config changes — reuse that set
|
|
// (the authoritative mirror of the backend `TRIGGER_COMPARE_IGNORE`).
|
|
function stripScheduleTriggerRuntime(row: any): Record<string, unknown> {
|
|
if (!row || typeof row !== 'object') return {}
|
|
return Object.fromEntries(Object.entries(row).filter(([k]) => !TRIGGER_RUNTIME_IGNORE.has(k)))
|
|
}
|
|
|
|
/**
|
|
* Project a variable/resource/schedule/trigger value — given in either its
|
|
* deployed backend-row shape or its draft editor-state shape — onto one
|
|
* canonical field set, so the deployed and draft sides of a diff are comparable
|
|
* and read as labeled rows instead of structural noise (variable `variable.value`
|
|
* vs `value`, resource `args` vs `value`, schedule/trigger runtime fields). This
|
|
* matches the shaping the compare page applies via `getItemValue`. `isDraft`
|
|
* selects the editor-state field names; secret variable values are masked.
|
|
*/
|
|
function canonicalizeDraftDiffValue(kind: DraftKind, raw: any, isDraft: boolean): unknown {
|
|
if (!raw || typeof raw !== 'object') return raw ?? {}
|
|
if (kind === 'variable') {
|
|
// draft: { variable: { value, is_secret, description }, labels, wsSpecific }
|
|
// deployed row: { value, is_secret, description, labels, ws_specific }
|
|
const v = isDraft ? (raw.variable ?? {}) : raw
|
|
const is_secret = !!v.is_secret
|
|
return {
|
|
value: is_secret ? '<secret>' : (v.value ?? ''),
|
|
is_secret,
|
|
description: v.description ?? '',
|
|
labels: raw.labels ?? undefined,
|
|
ws_specific: (isDraft ? raw.wsSpecific : raw.ws_specific) ?? undefined
|
|
}
|
|
}
|
|
if (kind === 'resource') {
|
|
// draft: { args, description, resource_type, labels, wsSpecific }
|
|
// deployed row: { value, description, resource_type, labels, ws_specific }
|
|
return {
|
|
value: (isDraft ? raw.args : raw.value) ?? {},
|
|
description: raw.description ?? '',
|
|
resource_type: raw.resource_type ?? undefined,
|
|
labels: raw.labels ?? undefined,
|
|
ws_specific: (isDraft ? raw.wsSpecific : raw.ws_specific) ?? undefined
|
|
}
|
|
}
|
|
// schedule + triggers: same field names on both sides — drop runtime noise.
|
|
return stripScheduleTriggerRuntime(raw)
|
|
}
|
|
|
|
/**
|
|
* Fetch the deployed value and the draft value for an item, for the DiffDrawer
|
|
* (`mode: 'simple'`, original = deployed, current = draft). For a `draft_only`
|
|
* item there is no real deployed value, so the deployed side is a minimal
|
|
* empty-but-valid shape and the draft shows as entirely new. DiffDrawer cleans
|
|
* both sides via `cleanValueProperties`, so raw objects are fine here.
|
|
*/
|
|
export async function getDraftDiffValues(
|
|
kind: DraftKind,
|
|
path: string,
|
|
workspace: string,
|
|
draftOnly = false
|
|
): Promise<DraftDiffValues> {
|
|
// A `draft_only` item can keep its content in the row itself with no separate
|
|
// draft-table row (e.g. a flow created via createFlow(draft_only: true), like
|
|
// `u/admin/new`). There `draft` is null, so the draft side must fall back to
|
|
// the row's own value — otherwise the diff "after" is empty and nothing shows.
|
|
// Strip overlay metadata (is_draft / draft_saved_at / draft_base / no_deployed /
|
|
// other_drafts_users) from the deployed side so the diff doesn't show the
|
|
// per-user markers as noise.
|
|
if (kind === 'script') {
|
|
const r = (await ScriptService.getScriptByPath({ workspace, path, getDraft: true })) as any
|
|
const {
|
|
draft,
|
|
is_draft: _i,
|
|
draft_saved_at: _c,
|
|
draft_base: _b,
|
|
no_deployed,
|
|
other_drafts_users: _o,
|
|
hash: _h,
|
|
...deployed
|
|
} = r
|
|
const draftValue = stripScriptRowRuntime(draft ?? deployed)
|
|
return {
|
|
deployed: draftOnly ? EMPTY_DEPLOYED.script!(draftValue) : stripScriptRowRuntime(deployed),
|
|
draft: draftValue,
|
|
hasDraft: draft != null,
|
|
noDeployed: no_deployed === true
|
|
}
|
|
} else if (kind === 'flow') {
|
|
const r = (await FlowService.getFlowByPath({ workspace, path, getDraft: true })) as any
|
|
const {
|
|
draft,
|
|
is_draft: _i,
|
|
draft_saved_at: _c,
|
|
draft_base: _b,
|
|
no_deployed,
|
|
other_drafts_users: _o,
|
|
version_id: _v,
|
|
...deployed
|
|
} = r
|
|
// Strip the draft's pinned base `version_id` (which differs from the deployed
|
|
// head for a stale draft) so it never renders as a spurious diff line.
|
|
const { version_id: _dv, ...draftValue } = (draft ?? deployed) as any
|
|
return {
|
|
deployed: draftOnly ? EMPTY_DEPLOYED.flow!(draftValue) : deployed,
|
|
draft: draftValue,
|
|
hasDraft: draft != null,
|
|
noDeployed: no_deployed === true
|
|
}
|
|
} else if (kind === 'app' || kind === 'raw_app') {
|
|
// A never-deployed raw app has no `app` row; the backend resolves the
|
|
// draft kind from `rawApp`, so it MUST be set or the lookup 404s.
|
|
const r = (await AppService.getAppByPath({
|
|
workspace,
|
|
path,
|
|
getDraft: true,
|
|
rawApp: kind === 'raw_app'
|
|
})) as any
|
|
if (kind === 'raw_app' || r.raw_app === true) {
|
|
// Raw-app drafts are stored flat (files/runnables/data top-level) while the
|
|
// deployed row nests them under `value`, and deployed inline scripts carry
|
|
// server-recomputed locks. Canonicalize both onto the same shape with the
|
|
// post-deploy noise stripped — the same module the editor's Diff button uses.
|
|
// A staged rename (`draft_path`) changes where deploy lands the app —
|
|
// compare it as `path` on both sides so a rename-only draft diffs.
|
|
const rawDraftPath = (r.draft?.draft_path as string | undefined) ?? r.path
|
|
return {
|
|
deployed: draftOnly
|
|
? canonicalRawAppDiffValue({})
|
|
: { ...canonicalRawAppDiffValue(r), path: r.path },
|
|
draft: { ...canonicalRawAppDiffValue(r.draft ?? r), path: rawDraftPath },
|
|
hasDraft: r.draft != null,
|
|
noDeployed: r.no_deployed === true
|
|
}
|
|
}
|
|
// Classic app: the editor drafts the bare grid with summary/draft_path
|
|
// mirrored into it, while the row keeps summary as a column beside
|
|
// `value`. Both sides reduce to `{ summary, value }` with the metadata
|
|
// extracted from the grid, so a summary edit diffs as a summary edit and
|
|
// the grid never diffs against draft-only markers.
|
|
const deployedParts = classicAppDraftParts(r.value)
|
|
const draftParts = r.draft != null ? classicAppDraftParts(r.draft) : deployedParts
|
|
const deployed = { summary: r.summary ?? '', value: deployedParts.value, path: r.path }
|
|
return {
|
|
deployed: draftOnly ? EMPTY_DEPLOYED.app!(undefined) : deployed,
|
|
draft: {
|
|
summary: draftParts.summary ?? r.summary ?? '',
|
|
value: draftParts.value,
|
|
path: draftParts.draftPath ?? r.path
|
|
},
|
|
hasDraft: r.draft != null,
|
|
noDeployed: r.no_deployed === true
|
|
}
|
|
} else {
|
|
// Variables / resources / schedules / triggers: one overlay GET yields
|
|
// both sides, but the draft side is the editor's state shape while the
|
|
// deployed side is the backend row — they diverge enough to make a raw
|
|
// diff pure noise. Canonicalize both onto a shared field set (same shaping
|
|
// the compare page's `getItemValue` applies) so only real changes show.
|
|
const getter = OVERLAY_GETTERS[kind]
|
|
if (!getter) {
|
|
throw new Error(`Draft diff not supported for kind ${kind}`)
|
|
}
|
|
const { deployed, draft, hasDraft, noDeployed } = splitOverlay(await getter(workspace, path))
|
|
return {
|
|
deployed: draftOnly ? {} : canonicalizeDraftDiffValue(kind, deployed, false),
|
|
draft: canonicalizeDraftDiffValue(kind, draft, true),
|
|
hasDraft,
|
|
noDeployed
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether a draft's base is stale: the deployed version the draft forked from
|
|
* (`draft_base`, text whatever the kind) no longer matches the current deployed
|
|
* head — a newer version was deployed after the draft began, so deploying the
|
|
* draft would silently revert it. The head is the deployed `hash` for scripts,
|
|
* `version_id` for flows, the last of `versions` for apps (incl. raw). `r` is
|
|
* the item fetched with `get_draft=true`.
|
|
*/
|
|
export function draftBaseIsStale(draftKind: UserDraftItemKind, r: any): boolean {
|
|
const base = r?.draft_base
|
|
if (base == null) return false
|
|
const head =
|
|
draftKind === 'script'
|
|
? r.hash
|
|
: draftKind === 'flow'
|
|
? r.version_id
|
|
: Array.isArray(r.versions)
|
|
? r.versions[r.versions.length - 1]
|
|
: undefined
|
|
return head != null && String(head) !== base
|
|
}
|
|
|
|
/** Fetch-and-test wrapper over `draftBaseIsStale` for one draft item. Returns
|
|
* false for kinds without a base pointer and on fetch errors (warn, not block). */
|
|
export async function fetchDraftBaseStale(
|
|
draftKind: UserDraftItemKind,
|
|
path: string,
|
|
workspace: string
|
|
): Promise<boolean> {
|
|
try {
|
|
if (draftKind === 'script') {
|
|
const r = await ScriptService.getScriptByPath({ workspace, path, getDraft: true })
|
|
return draftBaseIsStale(draftKind, r)
|
|
}
|
|
if (draftKind === 'flow') {
|
|
const r = await FlowService.getFlowByPath({ workspace, path, getDraft: true })
|
|
return draftBaseIsStale(draftKind, r)
|
|
}
|
|
if (draftKind === 'app' || draftKind === 'raw_app') {
|
|
// The apps endpoint auto-detects a raw app and overlays its draft.
|
|
const r = await AppService.getAppByPath({ workspace, path, getDraft: true })
|
|
return draftBaseIsStale(draftKind, r)
|
|
}
|
|
return false
|
|
} catch (e) {
|
|
console.error(`Stale-draft check failed for ${draftKind}:${path}`, e)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deploy a script/flow draft's trigger changes the same way the editors do.
|
|
* Scripts and flows can carry `draft_triggers`; the create/update call below
|
|
* deletes the draft row, so without this the saved trigger edits would be
|
|
* silently lost. Uses the shared `deployTriggers` (a throwaway `usedTriggerKinds`
|
|
* store is fine — it only tracks kinds for the editor UI). `isNew` forces each
|
|
* trigger's `script_path` to the deployed path (matches the editors' new path).
|
|
*/
|
|
async function deployDraftTriggers(
|
|
draftTriggers: Trigger[] | undefined,
|
|
workspace: string,
|
|
path: string,
|
|
isNew: boolean
|
|
): Promise<void> {
|
|
const triggers = (draftTriggers ?? []).filter((t) => t?.draftConfig)
|
|
if (triggers.length === 0) return
|
|
const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin)
|
|
await deployTriggers(triggers, workspace, isAdmin, writable<string[]>([]), path, isNew)
|
|
}
|
|
|
|
/**
|
|
* Promote a draft to deployed by replaying the editor's create/update call with
|
|
* the stored draft value. The matching draft row is deleted server-side by the
|
|
* create/update handler. Returns the same `{ success, error? }` shape as the
|
|
* fork-merge `deployItem`, so callers can reuse the `deploymentStatus` pattern.
|
|
*/
|
|
export async function deployDraft(
|
|
kind: DraftKind,
|
|
path: string,
|
|
workspace: string,
|
|
opts: { draftOnly?: boolean; rawApp?: boolean; deploymentMessage?: string } = {}
|
|
): Promise<DeployResult & { noop?: boolean }> {
|
|
const { draftOnly = false, rawApp = false, deploymentMessage } = opts
|
|
// Set when the branch found nothing to promote and wrote nothing. Success, because the item is
|
|
// already at the value a deploy would have left it at and its stale draft state still wants
|
|
// clearing — but a caller deploying one specific draft it showed the user has to be able to tell
|
|
// that apart from having deployed it.
|
|
let noop = false
|
|
try {
|
|
if (kind === 'raw_app' || (kind === 'app' && rawApp)) {
|
|
// Raw apps bundle their source files and deploy via the raw-app
|
|
// endpoints. Reached as `kind === 'raw_app'` (Review & Deploy) or
|
|
// `kind === 'app'` + `rawApp` (editor). Must route here: the
|
|
// visual-app branch would `updateApp` with no `value` (RawAppDraft
|
|
// has none) and silently drop the draft's files.
|
|
await deployRawAppDraft(workspace, path, deploymentMessage)
|
|
} else if (kind === 'script') {
|
|
const r = (await ScriptService.getScriptByPath({ workspace, path, getDraft: true })) as any
|
|
const d = r.draft ?? r
|
|
// Drop editor-only / server-managed keys; deploy as a real (non-draft) version.
|
|
const { draft_triggers: draftTriggers, draft_only: _o, ...rest } = d
|
|
const scriptPath = d.path ?? path
|
|
// Deploy at the draft's path so a rename in the draft is honored (same as
|
|
// the editor: createScript at the new path with parent_hash links lineage).
|
|
await ScriptService.createScript({
|
|
workspace,
|
|
requestBody: {
|
|
...rest,
|
|
path: scriptPath,
|
|
parent_hash: r.hash,
|
|
deployment_message: deploymentMessage,
|
|
// Deploy the draft's on-behalf-of as-is; the backend resets it to the
|
|
// deploying user without this flag, gated by can_preserve_on_behalf_of.
|
|
preserve_on_behalf_of: rest.on_behalf_of_email ? true : undefined
|
|
}
|
|
})
|
|
// Then deploy any draft trigger edits, so they aren't dropped with the draft.
|
|
await deployDraftTriggers(draftTriggers, workspace, scriptPath, true)
|
|
} else if (kind === 'flow') {
|
|
const r = (await FlowService.getFlowByPath({ workspace, path, getDraft: true })) as any
|
|
const d = r.draft ?? r
|
|
const requestBody = {
|
|
// Deploy at the draft's intended path: flow/app/raw-app drafts keep the
|
|
// user-typed path in `draft_path` (a never-deployed item is parked at a
|
|
// synthetic `u/{user}/draft_{uuid}` storage key). The URL `path` stays
|
|
// that storage key.
|
|
path: d.draft_path ?? d.path ?? path,
|
|
summary: d.summary ?? '',
|
|
description: d.description ?? '',
|
|
value: d.value,
|
|
schema: d.schema,
|
|
tag: d.tag,
|
|
dedicated_worker: d.dedicated_worker,
|
|
ws_error_handler_muted: d.ws_error_handler_muted,
|
|
visible_to_runner_only: d.visible_to_runner_only,
|
|
on_behalf_of_email: d.on_behalf_of_email,
|
|
on_behalf_of: d.on_behalf_of,
|
|
// Same as scripts and apps: the backend resets on_behalf_of_email to the
|
|
// deploying user without this flag, gated by can_preserve_on_behalf_of.
|
|
preserve_on_behalf_of: d.on_behalf_of_email ? true : undefined,
|
|
labels: d.labels,
|
|
deployment_message: deploymentMessage
|
|
}
|
|
// Draft-only flows have NO flow row (they live solely in the
|
|
// draft table), so they deploy via createFlow; a draft on a
|
|
// deployed flow updates it.
|
|
if (draftOnly) {
|
|
await FlowService.createFlow({ workspace, requestBody })
|
|
} else {
|
|
await FlowService.updateFlow({ workspace, path, requestBody })
|
|
}
|
|
// Then deploy any draft trigger edits, so they aren't dropped with the draft.
|
|
await deployDraftTriggers(
|
|
d.draft_triggers,
|
|
workspace,
|
|
d.draft_path ?? d.path ?? path,
|
|
draftOnly
|
|
)
|
|
} else if (kind === 'app') {
|
|
// `raw_app` is handled above; only visual apps reach here.
|
|
const r = (await AppService.getAppByPath({ workspace, path, getDraft: true })) as any
|
|
// A visual-app draft is stored as the *bare* app value (grid/theme/...,
|
|
// plus a `draft_path` when the path was renamed) — NOT wrapped in
|
|
// { value, summary, policy } like script/flow drafts. So the deploy value
|
|
// is the draft object itself; fall back to the deployed value when there's
|
|
// no draft. `draft_path` and `summary` are draft-only fields mirrored onto
|
|
// the App value (the editor drops them on deploy), so strip them from the
|
|
// value and apply them as the deploy path / summary column.
|
|
const draft = r.draft as Record<string, any> | undefined
|
|
const { draft_path: draftPath, summary: draftSummary, ...appValue } = draft ?? r.value ?? {}
|
|
// Policy isn't carried in the app draft, so it comes from the deployed app
|
|
// (or a default). custom_path requires admin on update; non-admins send
|
|
// undefined so the backend preserves the existing route. The draft has no
|
|
// custom_path, so admins fall back to the deployed route (`''` when none).
|
|
const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin)
|
|
const policy = r.policy ?? { execution_mode: 'publisher' }
|
|
const requestBody = {
|
|
value: appValue,
|
|
summary: draftSummary ?? r.summary ?? '',
|
|
policy,
|
|
// Honor the draft's intended path; `draft_path` holds the user-typed path
|
|
// for a never-deployed app parked at a `u/{user}/draft_{uuid}` storage key.
|
|
path: draftPath ?? r.path ?? path,
|
|
custom_path: isAdmin ? (r.custom_path ?? '') : undefined,
|
|
deployment_message: deploymentMessage,
|
|
// The draft carries no on-behalf-of selector — the policy comes straight
|
|
// from the deployed app. Preserve its on_behalf_of (the backend resets it
|
|
// to the deploying user without this flag, gated by can_preserve_on_behalf_of).
|
|
preserve_on_behalf_of: policy?.on_behalf_of ? true : undefined
|
|
}
|
|
// Same as flows: draft-only apps have no app row → create;
|
|
// drafts on a deployed app update it.
|
|
if (draftOnly) {
|
|
await AppService.createApp({ workspace, requestBody })
|
|
} else {
|
|
await AppService.updateApp({ workspace, path, requestBody })
|
|
}
|
|
} else if (kind === 'variable') {
|
|
const { deployed, draft: d } = splitOverlay(await OVERLAY_GETTERS.variable!(workspace, path))
|
|
// VariableEditor's `VariableState` draft shape:
|
|
// { path, variable: { value, is_secret, description }, labels?, wsSpecific }
|
|
if (draftOnly) {
|
|
await VariableService.createVariable({
|
|
workspace,
|
|
requestBody: {
|
|
path: d.path ?? path,
|
|
value: d.variable?.value ?? '',
|
|
is_secret: !!d.variable?.is_secret,
|
|
description: d.variable?.description ?? '',
|
|
labels: d.labels,
|
|
ws_specific: d.wsSpecific
|
|
}
|
|
})
|
|
} else {
|
|
await VariableService.updateVariable({
|
|
workspace,
|
|
path,
|
|
requestBody: {
|
|
path: d.path !== path ? d.path : undefined,
|
|
// '' = untouched secret value; sending it would blank the secret.
|
|
value: d.variable?.value === '' ? undefined : d.variable?.value,
|
|
is_secret: d.variable?.is_secret,
|
|
description: d.variable?.description,
|
|
labels: d.labels,
|
|
ws_specific: d.wsSpecific
|
|
}
|
|
})
|
|
}
|
|
void deployed
|
|
} else if (kind === 'resource') {
|
|
const overlay = await OVERLAY_GETTERS.resource!(workspace, path)
|
|
// Adopt the row this promote is based on as the baseline for the delete below. Without one
|
|
// the backend deletes unconditionally, so a draft saved between that read and the delete is
|
|
// destroyed having never been deployed — a caller that only ever read through a listing has
|
|
// no baseline of its own to supply. With it the delete is refused instead and the newer
|
|
// draft survives, which is the recoverable outcome of the two. Only ever seeded, never
|
|
// cleared: passing no timestamp drops whatever baseline the tab already held, which would
|
|
// turn that same delete back into an unconditional one.
|
|
if (overlay?.draft_saved_at) {
|
|
UserDraftDbSyncer.recordRemoteSync(
|
|
{ workspace, itemKind: kind, path },
|
|
overlay.draft_saved_at
|
|
)
|
|
}
|
|
const { deployed, draft: d, hasDraft } = splitOverlay(overlay)
|
|
// ResourceEditor's `ResourceState` draft shape:
|
|
// { path, description, args, resource_type?, labels?, wsSpecific }
|
|
// The deployed row is a different shape (`value`, `ws_specific`, no `args` at all), and
|
|
// `splitOverlay` hands it back as the draft side when the draft row has gone — deployed or
|
|
// discarded from another tab between the listing and this click. Reading it as a draft is
|
|
// what made `value: d.args ?? {}` replace a live resource with `{}`. Nothing to promote
|
|
// then, so write nothing and fall through to the cleanup below, which clears the stale
|
|
// local draft hint and the drafts listing. The item is already at the value a successful
|
|
// deploy would have left it at, so this reports success rather than an error, matching
|
|
// what the other kinds end up doing when their own draft is gone.
|
|
if (!hasDraft) {
|
|
noop = true
|
|
} else if (draftOnly) {
|
|
await ResourceService.createResource({
|
|
workspace,
|
|
requestBody: {
|
|
path: d.path ?? path,
|
|
value: d.args ?? {},
|
|
description: d.description ?? '',
|
|
resource_type: d.resource_type ?? deployed.resource_type,
|
|
labels: d.labels,
|
|
ws_specific: d.wsSpecific
|
|
}
|
|
})
|
|
} else {
|
|
await ResourceService.updateResource({
|
|
workspace,
|
|
path,
|
|
requestBody: {
|
|
path: d.path ?? path,
|
|
value: d.args ?? {},
|
|
description: d.description ?? '',
|
|
labels: d.labels,
|
|
ws_specific: d.wsSpecific
|
|
}
|
|
})
|
|
}
|
|
} else if (kind === 'trigger_schedule') {
|
|
const { draft: d } = splitOverlay(await OVERLAY_GETTERS.trigger_schedule!(workspace, path))
|
|
// The schedule editor's draft IS the cfg shape `saveScheduleFromCfg`
|
|
// consumes — same save the editor's Deploy button runs.
|
|
const ok = await saveScheduleFromCfg({ ...d, path: d.path ?? path }, !draftOnly, workspace)
|
|
if (!ok) {
|
|
return { success: false, error: 'Schedule save failed' }
|
|
}
|
|
} else if (kind in TRIGGER_SAVERS) {
|
|
const getter = OVERLAY_GETTERS[kind]!
|
|
const { draft: d } = splitOverlay(await getter(workspace, path))
|
|
const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin)
|
|
const ok = await TRIGGER_SAVERS[kind]!(
|
|
path,
|
|
{ ...d, path: d.path ?? path },
|
|
!draftOnly,
|
|
workspace,
|
|
isAdmin
|
|
)
|
|
if (!ok) {
|
|
return { success: false, error: 'Trigger save failed' }
|
|
}
|
|
} else {
|
|
return { success: false, error: `Deploy not supported for draft kind ${kind}` }
|
|
}
|
|
// Delete the draft at its STORAGE path (the row key, = the `path` arg).
|
|
// Two reasons it must happen here for every kind that promoted something,
|
|
// mirroring the editors' post-deploy `discardDraftAfterDeploy(draftPath)`:
|
|
// - Drawer kinds (variable / resource / triggers) aren't deleted by
|
|
// their create/update endpoints at all.
|
|
// - script/flow/app/raw_app DO delete server-side, but only the draft at
|
|
// the *deployed* path (`d.path`). A renamed draft_only item lives at a
|
|
// synthetic `u/{user}/draft_{uuid}` storage path ≠ `d.path`, so its
|
|
// draft row survives the deploy and keeps listing. Deleting the
|
|
// storage-path draft removes it (a no-op when the server already did).
|
|
// Skipped when nothing was promoted: the read that set `noop` found no draft of this user's to
|
|
// delete, so the only row this could reach is one written after it — destroying an edit that
|
|
// was never deployed, and never even listed.
|
|
if (!noop) {
|
|
await UserDraftDbSyncer.save({
|
|
workspace,
|
|
itemKind: kind,
|
|
path,
|
|
value: null,
|
|
immediate: true
|
|
})
|
|
}
|
|
// Mutated the workspace's Server Drafts — refresh every mounted reader.
|
|
invalidateWorkspaceDrafts(workspace)
|
|
// The DEPLOYED state moved: cached fork comparisons involving this
|
|
// workspace (as fork or as parent) are no longer trustworthy. Draft-only
|
|
// mutations skip this — they never move the deployed tally.
|
|
invalidateWorkspaceComparison(workspace)
|
|
// For script/flow/app the server-side delete bypasses UserDraftDbSyncer,
|
|
// so the syncer-owned hint won't auto-clear — clear it explicitly.
|
|
// (Idempotent: the drawer-kind delete above already cleared it.)
|
|
setLocalDraftHint(workspace, kind, path, false)
|
|
return noop ? { success: true, noop: true } : { success: true }
|
|
} catch (e: any) {
|
|
return { success: false, error: e?.body ?? e?.message ?? String(e) }
|
|
}
|
|
}
|
|
|
|
/** Kind → editor save helper for the standalone trigger kinds, all sharing the
|
|
* `(initialPath, cfg, edit, workspace, isAdmin?)` shape. The throwaway
|
|
* `usedTriggerKinds` store only feeds the editors' kind-usage UI. */
|
|
const TRIGGER_SAVERS: Partial<
|
|
Record<
|
|
DraftKind,
|
|
(
|
|
initialPath: string,
|
|
cfg: Record<string, any>,
|
|
edit: boolean,
|
|
workspace: string,
|
|
isAdmin: boolean
|
|
) => Promise<boolean>
|
|
>
|
|
> = {
|
|
trigger_http: (p, cfg, edit, ws, isAdmin) =>
|
|
saveHttpRouteFromCfg(p, cfg, edit, ws, isAdmin, writable<string[]>([])),
|
|
trigger_websocket: (p, cfg, edit, ws) =>
|
|
saveWebsocketTriggerFromCfg(p, cfg, edit, ws, writable<string[]>([])),
|
|
trigger_postgres: (p, cfg, edit, ws) =>
|
|
savePostgresTriggerFromCfg(p, cfg, edit, ws, writable<string[]>([])),
|
|
trigger_kafka: (p, cfg, edit, ws) =>
|
|
saveKafkaTriggerFromCfg(p, cfg, edit, ws, writable<string[]>([])),
|
|
trigger_nats: (p, cfg, edit, ws) =>
|
|
saveNatsTriggerFromCfg(p, cfg, edit, ws, writable<string[]>([])),
|
|
trigger_mqtt: (p, cfg, edit, ws) =>
|
|
saveMqttTriggerFromCfg(p, cfg, edit, ws, writable<string[]>([])),
|
|
trigger_amqp: (p, cfg, edit, ws) =>
|
|
saveAmqpTriggerFromCfg(p, cfg, edit, ws, writable<string[]>([])),
|
|
trigger_sqs: (p, cfg, edit, ws) =>
|
|
saveSqsTriggerFromCfg(p, cfg, edit, ws, writable<string[]>([])),
|
|
trigger_gcp: (p, cfg, edit, ws) =>
|
|
saveGcpTriggerFromCfg(p, cfg, edit, ws, writable<string[]>([])),
|
|
trigger_azure: (p, cfg, edit, ws) =>
|
|
saveAzureTriggerFromCfg(p, cfg, edit, ws, writable<string[]>([])),
|
|
trigger_email: (p, cfg, edit, ws, isAdmin) =>
|
|
saveEmailTriggerFromCfg(p, cfg, edit, ws, isAdmin, writable<string[]>([]))
|
|
}
|
|
|
|
/**
|
|
* Discard a draft. Draft-only items exist only as a draft-table row, so
|
|
* deleting that row is the whole discard in every case. `save({ value: null })`
|
|
* is the canonical "drop my draft for this path" POST.
|
|
*
|
|
* A legacy draft (workspace-level, `email IS NULL`) isn't owned by the authed
|
|
* user, so the email-scoped syncer delete can't reach it. Discard it via a
|
|
* direct `legacy` delete instead — then clear the local hint + invalidate as
|
|
* the syncer path would.
|
|
*/
|
|
export async function discardDraft(
|
|
kind: DraftKind,
|
|
path: string,
|
|
workspace: string,
|
|
_draftOnly = false,
|
|
legacy = false,
|
|
// Batch callers pass false and invalidate once after the last discard —
|
|
// per-item invalidation would refetch the whole draft list N times, with
|
|
// overlapping responses able to land out of order.
|
|
invalidate = true
|
|
): Promise<DeployResult> {
|
|
try {
|
|
if (legacy) {
|
|
await DraftService.updateDraft({
|
|
workspace,
|
|
kind,
|
|
path,
|
|
requestBody: { value: null, legacy: true }
|
|
})
|
|
setLocalDraftHint(workspace, kind, path, false)
|
|
if (invalidate) invalidateWorkspaceDrafts(workspace)
|
|
return { success: true }
|
|
}
|
|
// postSave clears the syncer-owned `*` hint on the delete. `immediate`
|
|
// so the await resolves after the POST lands — else it resolves at
|
|
// enqueue time and the invalidate below refetches before the delete,
|
|
// re-listing the just-discarded draft.
|
|
await UserDraftDbSyncer.save({ workspace, itemKind: kind, path, value: null, immediate: true })
|
|
if (invalidate) invalidateWorkspaceDrafts(workspace)
|
|
return { success: true }
|
|
} catch (e: any) {
|
|
return { success: false, error: e?.body ?? e?.message ?? String(e) }
|
|
}
|
|
}
|