Compare commits

...
Author SHA1 Message Date
Ruben FiszelandClaude Opus 5 79f61f49b5 fix: tell the user when a draft conflict has stopped their edits saving
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 21:36:07 +02:00
f00b2fcb1e feat: drafts follow their item through a move; behind means base ≠ head (#10577)
* 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>
2026-09-18 20:54:23 +02:00
Ruben FiszelandClaude Opus 5 974b0265f8 fix(cli): read wmill.yaml from the branch a git-sync deploy writes to (#11236)
* fix(cli): read wmill.yaml from the branch a git-sync deploy writes to

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

* fix(cli): create the stateful dir once the deploy branch's config is read

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:50:23 +02:00
Alexander PetricandClaude Fable 5.1 8f9a94328c give the rich text editor toolbar a bullet list button instead of a second ordered one (#11233)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:48:07 +02:00
Alexander PetricandClaude Fable 5.1 3ce158ad58 chore(frontend): upgrade quill to 2.0.3 (#11231)
* chore(frontend): upgrade quill to 2.0.3

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(apps): load rich text editor HTML through Quill's converter so stored lists survive Quill 2

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(apps): emit standard ul/ol from the rich text editor output under Quill 2

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(apps): keep checklist state when normalising the rich text editor output

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:47:44 +02:00
103 changed files with 5496 additions and 813 deletions
@@ -0,0 +1,43 @@
{
"db_name": "PostgreSQL",
"query": "SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by\n FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash\n WHERE s.workspace_id = $1 AND s.path = $2\n ORDER by s.created_at DESC\n LIMIT $3 OFFSET $4",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "hash",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "deployment_msg",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "created_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false,
true,
false,
false
]
},
"hash": "044310c0e7745867a24a9d3653ff29c87ce48ffd89fac070c8314556f884ce55"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft AS d\n SET path = $3::text,\n value = (\n SELECT CASE\n WHEN s.clean -> 'path' = to_jsonb($2::text)\n OR s.clean -> 'draft_path' = to_jsonb($2::text)\n THEN to_json(\n s.clean\n || CASE WHEN s.clean -> 'path' = to_jsonb($2::text)\n THEN jsonb_build_object('path', $3::text)\n ELSE '{}'::jsonb END\n || CASE WHEN s.clean -> 'draft_path' = to_jsonb($2::text)\n THEN jsonb_build_object('draft_path', $3::text)\n ELSE '{}'::jsonb END\n )\n ELSE d.value\n END\n FROM (SELECT replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)),\n chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean) s\n )\n WHERE d.workspace_id = $1\n AND d.path = $2::text\n AND d.typ::text = ANY($4::text[])",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"TextArray"
]
},
"nullable": []
},
"hash": "09e3f8fa8a9e64048971983584bfa00ab137c70b73f381452cadfae6e1cda934"
}
@@ -0,0 +1,71 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\n created_at, base\n FROM draft\n WHERE workspace_id = $1\n AND (email = $2 OR email IS NULL)\n AND path = $3\n AND typ = $4\n ORDER BY email NULLS LAST\n LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
"type_info": "Json"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "base",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"script",
"flow",
"app",
"raw_app",
"resource",
"variable",
"trigger_schedule",
"trigger_webhook",
"trigger_default_email",
"trigger_email",
"trigger_http",
"trigger_websocket",
"trigger_postgres",
"trigger_kafka",
"trigger_nats",
"trigger_mqtt",
"trigger_sqs",
"trigger_gcp",
"trigger_azure",
"trigger_poll",
"trigger_cli",
"trigger_nextcloud",
"trigger_google",
"trigger_github",
"data_pipeline",
"trigger_amqp"
]
}
}
}
]
},
"nullable": [
false,
false,
true
]
},
"hash": "0cc6770a81ecaecafe0d9b7100f94b329c4ec7978b016af4c964415e0c38396c"
}
@@ -0,0 +1,64 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft\n SET path = $3,\n -- Both path keys, not just the typed one: the editors mirror the\n -- typed path into the other while it differs from the row's path,\n -- and the loaders prefer the mirror — left naming the old location\n -- it un-does this move on the next save. `create_missing = false`\n -- on both, so a draft carrying only one keeps only one.\n value = to_json(\n jsonb_set(\n jsonb_set(\n CASE WHEN $7::text IS NULL THEN to_jsonb(value)\n ELSE jsonb_set(to_jsonb(value), ARRAY['summary'], to_jsonb($7::text))\n END,\n ARRAY[$5::text], to_jsonb($3::text), false\n ),\n ARRAY[$8::text], to_jsonb($3::text), false\n )\n )\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $4\n AND email = $6\n -- A pre-sanitizer NUL escape makes `to_jsonb` raise 22P05. Excluded\n -- here so the statement can't 500; reported below instead. Unlike the\n -- passive carry, rewriting the value IS this operation, so skipping it\n -- silently would move the row and leave its typed path stale.\n AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) = 0\n -- Skipped on a summary-only edit, where the \"target\" row is this\n -- row and the guard would refuse the update against itself.\n AND ($2 = $3 OR NOT EXISTS (\n SELECT 1 FROM draft o\n WHERE o.workspace_id = $1 AND o.path = $3 AND o.typ::text = ANY($9::text[])\n -- Of this kind only the caller's own row and the legacy one collide:\n -- teammates' drafts of one item share its path by design, but a deploy\n -- there wipes those two together, so a second would discard edits the\n -- caller never saw. The other app kind is a different item on the same\n -- deployed path, so it collides whoever owns it.\n AND (o.typ <> $4 OR o.email = $6 OR o.email IS NULL)\n ))\n RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"script",
"flow",
"app",
"raw_app",
"resource",
"variable",
"trigger_schedule",
"trigger_webhook",
"trigger_default_email",
"trigger_email",
"trigger_http",
"trigger_websocket",
"trigger_postgres",
"trigger_kafka",
"trigger_nats",
"trigger_mqtt",
"trigger_sqs",
"trigger_gcp",
"trigger_azure",
"trigger_poll",
"trigger_cli",
"trigger_nextcloud",
"trigger_google",
"trigger_github",
"data_pipeline",
"trigger_amqp"
]
}
}
},
"Text",
"Text",
"Text",
"Text",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "0d9a83d77632bc0de19a48eddb7e6f80b2a41db4f1d352920d0c295ea3a018b1"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft_move WHERE email = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "11af34e5fe5b8ed59453d58628ed5fb3b2120835158f22d715b73460f6a55108"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at\n FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash\n WHERE s.workspace_id = $1 AND s.path = $2\n ORDER by s.created_at DESC",
"query": "SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by\n FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash\n WHERE s.workspace_id = $1 AND s.path = $2\n ORDER by s.created_at DESC LIMIT 1",
"describe": {
"columns": [
{
@@ -17,6 +17,11 @@
"ordinal": 2,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "created_by",
"type_info": "Varchar"
}
],
"parameters": {
@@ -28,8 +33,9 @@
"nullable": [
false,
true,
false,
false
]
},
"hash": "9a1483a81f5b086e0765d3d69483e29b09f66090e1f9d394564c16d921d2e66c"
"hash": "148fbee57c6abbb6f361818c5b6bfc8a1db451d31dfddbb5be945b3551c8becb"
}
@@ -0,0 +1,49 @@
{
"db_name": "PostgreSQL",
"query": "SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg,\n av.created_by as created_by, av.created_at as created_at\n FROM app a\n JOIN LATERAL (\n SELECT av2.id, COALESCE(v.ord, 0) AS ord\n FROM app_version av2\n LEFT JOIN unnest(a.versions) WITH ORDINALITY AS v(id, ord) ON v.id = av2.id\n WHERE av2.app_id = a.id\n ORDER BY ord DESC, av2.id DESC\n LIMIT $3 OFFSET $4\n ) page ON TRUE\n JOIN app_version av ON av.id = page.id\n LEFT JOIN deployment_metadata dm ON av.id = dm.app_version\n WHERE a.workspace_id = $1 AND a.path = $2\n ORDER BY page.ord DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "app_id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "version_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "deployment_msg",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
true,
false,
false
]
},
"hash": "18b38efcb141101192b372e7d7e7e47481877f3ab717682f3b84e665c95a5214"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "WITH legacy AS (\n DELETE FROM draft\n WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL\n RETURNING value\n )\n INSERT INTO draft (workspace_id, email, path, typ, value, created_at)\n SELECT $1, $4, $2, $3, value, now() FROM legacy\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now()\n RETURNING 1 as \"one!\"",
"query": "WITH legacy AS (\n DELETE FROM draft\n WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL\n RETURNING value, base\n )\n INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)\n SELECT $1, $4, $2, $3, value, now(), base FROM legacy\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now(), base = EXCLUDED.base\n RETURNING 1 as \"one!\"",
"describe": {
"columns": [
{
@@ -55,5 +55,5 @@
null
]
},
"hash": "46f00a75b2e7e4ac70758a9687070f68bc0421f1aa228f80157adda63191d33b"
"hash": "1c6859291963a9ca38d73f500213a575bfcd4469524f0dc8812470737759f02b"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft_move (workspace_id, typ, old_path, new_path, email)\n SELECT m.workspace_id, m.typ, m.old_path, $4, $5::text\n FROM draft_move m\n WHERE m.workspace_id = $1 AND m.typ::text = ANY($2::text[])\n AND m.new_path = $3 AND m.email IS NULL\n AND NOT EXISTS (\n SELECT 1 FROM draft_move o\n WHERE o.workspace_id = m.workspace_id AND o.typ = m.typ\n AND o.old_path = m.old_path AND o.email = $5::text\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray",
"Text",
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "232ff36c2d522383061cafbf9639698dbc44a18c0f016aae94a1705f06fd1e48"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft_move (workspace_id, typ, old_path, new_path, email)\n SELECT $1, t::draft_kind, $3, $4, $5 FROM unnest($2::text[]) t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"TextArray",
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "3a015ab279785a15f89c89d8c4d155e85370d4c2b7ed14fbb510919e7624ca1c"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft_move SET new_path = $4\n WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND new_path = $3 AND ($5::text IS NULL OR email = $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray",
"Text",
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "3db5f1d8ec1f8d867cf01c066876122e78f0c76a93a1d9860e8dff05d9a02c09"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft_move dest\n WHERE dest.email = $1\n AND EXISTS (SELECT 1 FROM draft_move src\n WHERE src.email = $2\n AND src.workspace_id = dest.workspace_id\n AND src.old_path = dest.old_path\n AND src.typ = dest.typ)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "5959c60da91f1e62080a4455e2a4627cb1fd3d8c70210bf7f38cb222dd15d88f"
}
@@ -0,0 +1,72 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)\n VALUES ($1, $2, $3::text, $4,\n CASE WHEN $10::bool\n THEN to_json($5::text::jsonb || jsonb_strip_nulls(jsonb_build_object(\n 'path', CASE WHEN $5::text::jsonb -> 'path' = to_jsonb($11::text)\n THEN to_jsonb($3::text) END,\n 'draft_path', CASE WHEN $5::text::jsonb -> 'draft_path' = to_jsonb($11::text)\n THEN to_jsonb($3::text) END)))\n ELSE $5::text::json\n END,\n COALESCE($8::timestamptz, now()), $9)\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = CASE\n WHEN NOT $10::bool\n OR position(chr(92) || 'u0000' in replace(draft.value::text, chr(92) || chr(92), '')) > 0\n THEN EXCLUDED.value\n ELSE to_json((to_jsonb(EXCLUDED.value) - 'path' - 'draft_path')\n || jsonb_strip_nulls(jsonb_build_object(\n 'path', to_jsonb(draft.value) -> 'path',\n 'draft_path', to_jsonb(draft.value) -> 'draft_path')))\n END,\n created_at = EXCLUDED.created_at,\n base = EXCLUDED.base\n WHERE $7::bool = true\n OR $6::timestamptz IS NULL\n OR draft.created_at <= $6::timestamptz\n RETURNING path, created_at",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"script",
"flow",
"app",
"raw_app",
"resource",
"variable",
"trigger_schedule",
"trigger_webhook",
"trigger_default_email",
"trigger_email",
"trigger_http",
"trigger_websocket",
"trigger_postgres",
"trigger_kafka",
"trigger_nats",
"trigger_mqtt",
"trigger_sqs",
"trigger_gcp",
"trigger_azure",
"trigger_poll",
"trigger_cli",
"trigger_nextcloud",
"trigger_google",
"trigger_github",
"data_pipeline",
"trigger_amqp"
]
}
}
},
"Text",
"Timestamptz",
"Bool",
"Timestamptz",
"Text",
"Bool",
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "7761959d05fff8c314d04c1bc9fb4e84a61008dbec0995ae192f1b3a1c261579"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft_move\n WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND old_path IN ($3, $4) AND ($5::text IS NULL OR email = $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray",
"Varchar",
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "797896e0152046844dbfee9d0e57e0055d742e44119bfb31ae7ff37a89b34d14"
}
@@ -0,0 +1,43 @@
{
"db_name": "PostgreSQL",
"query": "SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version\n LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version\n WHERE flow_version.path = $1 AND flow_version.workspace_id = $2\n ORDER BY flow_version.created_at DESC\n LIMIT $3 OFFSET $4",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "deployment_msg",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
true
]
},
"hash": "7ff60d155510303e307c47dcb6153ccaac72871eb68822d8e296c0d747e55f35"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email)\n SELECT $2, path, typ,\n CASE WHEN typ IN ('script', 'flow')\n THEN to_json(to_jsonb(value) - 'on_behalf_of')\n ELSE value END,\n created_at, email\n FROM draft\n WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "87afd5289e54b6accda8a399ec3c66df3d19217059f62903be3c74eadb9c15d5"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft_move WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND (old_path = $3 OR (new_path = $3 AND $4::text IS NULL))",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "9723edfd6de38cf0bf21123a46d88fa7d0b650b5cfd80c099f785f5e411062e5"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft_move\n WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND new_path = $4 AND old_path <> $3 AND ($5::text IS NULL OR email = $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray",
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "9d1b202844e6935e636d1570a92d46af4419dda9410ffc46f19bc38e56efe609"
}
@@ -1,35 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version \n LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version\n WHERE flow_version.path = $1 AND flow_version.workspace_id = $2 \n ORDER BY flow_version.created_at DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "deployment_msg",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "a0f1c0df6bc2f1fbca50edee90e42c94445536e201b322eda6f7a90bdf38f36a"
}
@@ -0,0 +1,85 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n -- The guard's own predicate, ordered own row, then legacy, then another\n -- user's other-kind row: each needs a different remedy, and a bare\n -- LIMIT 1 would name an arbitrary one.\n (SELECT typ::text FROM draft WHERE workspace_id = $1 AND path = $3\n AND typ::text = ANY($6::text[])\n AND (typ <> $2 OR email = $4 OR email IS NULL)\n ORDER BY CASE WHEN email = $4 THEN 0 WHEN email IS NULL THEN 1 ELSE 2 END\n LIMIT 1) as \"at_target\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3\n AND typ::text = ANY($6::text[]) AND email = $4) as \"at_target_own!\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3\n AND typ::text = ANY($6::text[]) AND email IS NULL) as \"at_target_legacy!\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5\n AND typ = $2 AND email = $4\n AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0\n ) as \"poisoned!\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5\n AND typ = $2 AND email IS NULL) as \"legacy!\" ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "at_target",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "at_target_own!",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "at_target_legacy!",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "poisoned!",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "legacy!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"script",
"flow",
"app",
"raw_app",
"resource",
"variable",
"trigger_schedule",
"trigger_webhook",
"trigger_default_email",
"trigger_email",
"trigger_http",
"trigger_websocket",
"trigger_postgres",
"trigger_kafka",
"trigger_nats",
"trigger_mqtt",
"trigger_sqs",
"trigger_gcp",
"trigger_azure",
"trigger_poll",
"trigger_cli",
"trigger_nextcloud",
"trigger_google",
"trigger_github",
"data_pipeline",
"trigger_amqp"
]
}
}
},
"Text",
"Text",
"Text",
"TextArray"
]
},
"nullable": [
null,
null,
null,
null,
null
]
},
"hash": "a1ed94ef0a6d4f7bc89ac4a2341bc06dc95d60ec92469523935f5a18a4d3f907"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft_move SET workspace_id = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "a725d5b99b65a1637406179b058a1ff36620099c6b87fd82a59eae1e35303c07"
}
@@ -0,0 +1,47 @@
{
"db_name": "PostgreSQL",
"query": "SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg,\n av.created_by as created_by, av.created_at as created_at\n FROM app a JOIN app_version av\n ON av.id = a.versions[array_upper(a.versions, 1)] AND av.app_id = a.id\n LEFT JOIN deployment_metadata dm ON av.id = dm.app_version\n WHERE a.workspace_id = $1 AND a.path = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "app_id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "version_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "deployment_msg",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
true,
false,
false
]
},
"hash": "bd8ac169ad15346f28f561233fe340efd453512c1464a413af2393ab8bfea9ee"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft_move SET email = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "e428f0cdb9077bffe2b7271d9b5f30ed81e8c26a9f1e65f423078947153ff9dd"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base)\n SELECT $2, path, typ,\n to_json(\n CASE WHEN typ IN ('script', 'flow') THEN clean - 'on_behalf_of' ELSE clean END\n - CASE WHEN typ = 'flow' THEN 'version_id'\n WHEN typ IN ('app', 'raw_app') THEN 'parent_version'\n ELSE '' END\n ),\n created_at, email,\n CASE WHEN typ = 'script' THEN base END\n FROM (\n SELECT d.path, d.typ, d.created_at, d.email, d.base,\n replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean\n FROM draft d\n WHERE d.workspace_id = $1 AND (d.email = $3 OR d.email IS NULL)\n ) s",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "e6140682ebc65083f48714ab3299fdb239ef507e23003db03aff7d35b02c540b"
}
@@ -0,0 +1,59 @@
{
"db_name": "PostgreSQL",
"query": "SELECT m.new_path FROM draft_move m\n WHERE m.workspace_id = $1 AND m.typ = $2 AND m.old_path = $3\n AND (m.email IS NULL OR m.email = $4)\n AND NOT EXISTS (\n SELECT 1 FROM draft d\n WHERE d.workspace_id = $1 AND d.typ = $2 AND d.path = $3\n AND d.email IS NOT DISTINCT FROM $4\n )\n ORDER BY m.email IS NULL\n LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "new_path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"script",
"flow",
"app",
"raw_app",
"resource",
"variable",
"trigger_schedule",
"trigger_webhook",
"trigger_default_email",
"trigger_email",
"trigger_http",
"trigger_websocket",
"trigger_postgres",
"trigger_kafka",
"trigger_nats",
"trigger_mqtt",
"trigger_sqs",
"trigger_gcp",
"trigger_azure",
"trigger_poll",
"trigger_cli",
"trigger_nextcloud",
"trigger_google",
"trigger_github",
"data_pipeline",
"trigger_amqp"
]
}
}
},
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "e63da92247045f2aaaa62b9d6ee17510fd05d0b7c5311b61e7257ccd07b16da2"
}
@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version \n LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version\n WHERE flow_version.path = $1 AND flow_version.workspace_id = $2 \n ORDER BY flow_version.created_at DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "deployment_msg",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
true
]
},
"hash": "ef98b0fab16d3b19997fd639b74a895cce1e8ae010307431e6787d20c7b25780"
}
@@ -0,0 +1,31 @@
{
"db_name": "PostgreSQL",
"query": "SELECT d.email IS NULL as \"legacy!\", COALESCE(u.username, p.username) as username\n FROM draft d\n LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email\n LEFT JOIN password p ON p.email = d.email AND p.super_admin = true\n WHERE d.workspace_id = $1 AND d.path = $2 AND d.typ::text = ANY($3::text[])\n -- A row a move already brought here from the very path being renamed is this\n -- rename's own draft, not an item in its way: the record says which owners\n -- it moved (all of them for an item move, one for a draft-only move). Any\n -- other row is a second item and still collides.\n AND NOT EXISTS (\n SELECT 1 FROM draft_move m\n WHERE m.workspace_id = $1 AND m.typ::text = ANY($3::text[])\n AND m.old_path = $4 AND m.new_path = $2\n AND (m.email IS NULL OR m.email IS NOT DISTINCT FROM d.email)\n )\n ORDER BY 2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "legacy!",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "username",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"TextArray",
"Text"
]
},
"nullable": [
null,
null
]
},
"hash": "fc2081d9077c743bc3978ae1905db558549b2356fc6ac8f0557aa40e1345ff46"
}
@@ -0,0 +1 @@
ALTER TABLE draft DROP COLUMN base;
@@ -0,0 +1,14 @@
-- The version a draft forked from, as one opaque text id whatever the kind: a
-- script hash (hex), a flow_version.id, an app_version.id. NULL for a draft that
-- was never forked from a deploy and for kinds that keep no lineage.
ALTER TABLE draft ADD COLUMN base TEXT;
-- A U+0000 inside a `json` value makes `->>` raise 22P05; such rows keep NULL and
-- get their base on their next save.
UPDATE draft SET base = CASE typ::text
WHEN 'script' THEN value ->> 'parent_hash'
WHEN 'flow' THEN value ->> 'version_id'
ELSE value ->> 'parent_version'
END
WHERE typ::text IN ('script', 'flow', 'app', 'raw_app')
AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) = 0;
@@ -0,0 +1 @@
DROP TABLE draft_move;
@@ -0,0 +1,17 @@
-- Where an item's drafts went when it moved, so a draft save still addressed to the
-- old path (an editor left open across the move, a chat, the CLI) lands on the moved
-- draft instead of starting a new item there. `email` NULL records a deployed item's
-- move and applies to every user; set, it records that user's draft-only move.
CREATE TABLE draft_move (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
typ DRAFT_KIND NOT NULL,
old_path VARCHAR(255) NOT NULL,
new_path VARCHAR(255) NOT NULL,
email VARCHAR(255)
);
CREATE INDEX draft_move_old_path_idx ON draft_move (workspace_id, typ, old_path);
GRANT ALL ON draft_move TO windmill_user;
GRANT ALL ON draft_move TO windmill_admin;
+193
View File
@@ -0,0 +1,193 @@
//! The deployed order of an app's versions is the order they were appended to
//! `app.versions`, not the order of their `created_at`.
//!
//! `app_version.created_at` defaults to `now()`, which in Postgres is the
//! transaction's start time, while the append happens under the app row's lock.
//! Two deploys that overlap therefore land in one order and carry timestamps in
//! the other. The head the editor guards against, and the sequence the diff
//! picker numbers, both have to follow the array.
//!
//! Users from the `base` fixture: test-user (admin, token SECRET_TOKEN).
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const TOKEN: &str = "SECRET_TOKEN";
#[sqlx::test(fixtures("base"))]
async fn test_app_head_follows_the_append_order_not_the_timestamps(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let ws = format!(
"http://localhost:{}/api/w/test-workspace",
server.addr.port()
);
let client = reqwest::Client::new();
let res = client
.post(format!("{ws}/apps/create"))
.header("Authorization", format!("Bearer {TOKEN}"))
.json(&json!({
"path": "u/test-user/order_app",
"summary": "ordered",
"value": {},
"policy": { "execution_mode": "publisher", "triggerables": {} }
}))
.send()
.await?;
assert!(res.status().is_success(), "{}", res.text().await?);
let first: i64 = sqlx::query_scalar(
"SELECT versions[array_upper(versions, 1)] FROM app
WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/order_app'",
)
.fetch_one(&db)
.await?;
// The overlapping deploy: appended after `first`, so it is the version that
// landed, but stamped before it, so a timestamp sort puts it underneath.
let second: i64 = sqlx::query_scalar(
"INSERT INTO app_version (app_id, value, created_by, created_at, raw_app)
SELECT app_id, value, 'racer', created_at - interval '1 hour', raw_app
FROM app_version WHERE id = $1
RETURNING id",
)
.bind(first)
.fetch_one(&db)
.await?;
sqlx::query(
"UPDATE app SET versions = array_append(versions, $1::bigint)
WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/order_app'",
)
.bind(second)
.execute(&db)
.await?;
let head: serde_json::Value = client
.get(format!(
"{ws}/apps/get_latest_version/u/test-user/order_app"
))
.header("Authorization", format!("Bearer {TOKEN}"))
.send()
.await?
.json()
.await?;
assert_eq!(
head["version"], second,
"the head is the version appended last, not the newest timestamp: {head}"
);
let history: Vec<serde_json::Value> = client
.get(format!("{ws}/apps/history/p/u/test-user/order_app"))
.header("Authorization", format!("Bearer {TOKEN}"))
.send()
.await?
.json()
.await?;
let listed: Vec<i64> = history
.iter()
.map(|v| v["version"].as_i64().unwrap())
.collect();
assert_eq!(
listed,
vec![second, first],
"the history lists in deployed order, so it leads with the head"
);
// Enough versions that a page cannot hold them, so "asked for nothing" and "asked for
// a page" are visibly different answers.
let mut appended = vec![second, first];
for _ in 0..24 {
let extra: i64 = sqlx::query_scalar(
"INSERT INTO app_version (app_id, value, created_by, created_at, raw_app)
SELECT app_id, value, 'bulk', created_at, raw_app FROM app_version WHERE id = $1
RETURNING id",
)
.bind(first)
.fetch_one(&db)
.await?;
sqlx::query(
"UPDATE app SET versions = array_append(versions, $1::bigint)
WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/order_app'",
)
.bind(extra)
.execute(&db)
.await?;
appended.insert(0, extra);
}
let versions_at = |query: &str| {
let url = format!("{ws}/apps/history/p/u/test-user/order_app{query}");
let client = client.clone();
async move {
let rows: Vec<serde_json::Value> = client
.get(url)
.header("Authorization", format!("Bearer {TOKEN}"))
.send()
.await?
.json()
.await?;
Ok::<_, anyhow::Error>(
rows.iter()
.map(|v| v["version"].as_i64().unwrap())
.collect::<Vec<_>>(),
)
}
};
// The deployment-history panel and the CLI read this endpoint without paging, so
// asking for no page has to keep answering with the whole history.
assert_eq!(
versions_at("").await?,
appended,
"an unpaginated request still answers whole"
);
assert_eq!(
versions_at("?per_page=10").await?,
appended[..10],
"a page holds what was asked for, newest first"
);
assert_eq!(
versions_at("?per_page=10&page=2").await?,
appended[10..20],
"the next page carries on where the first left off, skipping nothing"
);
// A page past the end runs off it rather than overflowing into one. (The clamp on an
// asked-for size is pinned where it lives, in `paginate_optional`'s own test.)
assert!(
versions_at("?per_page=10&page=99999999")
.await?
.is_empty(),
"a page past the end is empty"
);
// Naming only the page still asks for a page, rather than an offset into a listing
// sized to hold everything — which would answer nothing at all.
assert_eq!(
versions_at("?page=1").await?,
appended,
"a first page with no size named holds the history"
);
// A version that never entered the deployed sequence — one a restore or a fork copy
// could leave behind — still has to be reachable, after the ones that did.
let stray: i64 = sqlx::query_scalar(
"INSERT INTO app_version (app_id, value, created_by, created_at, raw_app)
SELECT app_id, value, 'stray', created_at, raw_app FROM app_version WHERE id = $1
RETURNING id",
)
.bind(first)
.fetch_one(&db)
.await?;
let mut with_stray = appended.clone();
with_stray.push(stray);
assert_eq!(
versions_at("").await?,
with_stray,
"a version outside app.versions is listed, and after the deployed sequence"
);
Ok(())
}
+2 -4
View File
@@ -521,10 +521,8 @@ def main():
.await
.unwrap();
assert_eq!(
res.text().await.unwrap(),
"app f/rel/root_app updated (npath: \"f/rel/root_app_renamed\")"
);
let deployed: serde_json::Value = res.json().await.unwrap();
assert_eq!(deployed["path"], "f/rel/root_app_renamed", "{deployed}");
let mut completed = listen_for_completed_jobs(&db).await;
in_test_worker(&db, completed.next(), port).await;
+74
View File
@@ -0,0 +1,74 @@
//! Moving a draft must carry both of its path keys.
//!
//! A draft value holds a typed path and a mirror the editors keep beside it while
//! it differs from the row's path (`path`/`draft_path`; which is which depends on
//! the kind). The loaders prefer the mirror, so a move that rewrote only the typed
//! key left the mirror naming the old location: reopening the item restored the
//! old path, and the next autosave wrote it back — undoing the move silently.
//!
//! This pins `move_draft`, including that a draft with no mirror never gains one.
//! A deploy's carry follows the keys only where they still name the old path
//! (`drafts_save_follows_move` pins that).
use serde_json::Value;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
async fn move_to(port: u16, from: &str, to: &str) -> anyhow::Result<String> {
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/{from}"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&serde_json::json!({ "new_path": to }))
.send()
.await?;
// Asserted here so a rejected move fails as itself, rather than as a JSON
// parse error in the read-back below.
let status = resp.status();
let body = resp.text().await?;
assert!(status.is_success(), "move {from} -> {to} failed: {body}");
Ok(body)
}
/// The stored draft value at `path`, read back through the API so this test needs
/// no `sqlx::query!` (which would want an offline cache entry of its own).
async fn value_at(port: u16, path: &str) -> anyhow::Result<Value> {
let body: Value = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/get_own/script/{path}"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.json()
.await?;
Ok(body
.get("value")
.cloned()
.unwrap_or_else(|| panic!("no draft at {path}: {body}")))
}
#[sqlx::test(fixtures("base", "drafts_move_mirror"))]
async fn test_move_carries_both_path_keys(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
move_to(port, "u/test-user/draft_mirror", "u/test-user/renamed").await?;
move_to(port, "u/test-user/draft_plain", "u/test-user/plain2").await?;
// The mirror follows: left at `u/test-user/friendly` it would win at load and
// walk the item back there.
let moved = value_at(port, "u/test-user/renamed").await?;
assert_eq!(moved["path"], "u/test-user/renamed");
assert_eq!(moved["draft_path"], "u/test-user/renamed");
// A draft that never had a mirror must not be given one.
let plain = value_at(port, "u/test-user/plain2").await?;
assert_eq!(plain["path"], "u/test-user/plain2");
assert_eq!(plain.get("draft_path"), None, "mirror injected: {plain}");
Ok(())
}
+250
View File
@@ -0,0 +1,250 @@
//! A rename onto a path that already holds a draft is refused.
//!
//! Nothing deployed can sit at a rename's destination (the deploy conflicts on
//! that), but a draft can: a never-deployed item, or a draft left on an archived
//! script. Moving onto it would merge two items or strand a row, so the rename
//! itself fails, in its own transaction, and the source stays deployed. The
//! destination draft here is the deployer's own, which is the same collision.
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
/// Hex form of script hash 7010, the way the API takes a parent hash.
const HEAD_HASH: &str = "0000000000001b62";
#[sqlx::test(fixtures("base", "drafts_move_taken"))]
async fn test_rename_onto_a_draft_is_refused(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client = reqwest::Client::new();
let resp = client
.post(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/create"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"path": "u/test-user/mvtaken_b",
"parent_hash": HEAD_HASH,
"summary": "A",
"description": "",
"content": "export function main() { return 1 }",
"language": "deno",
"schema": {}
}))
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status, 400, "rename onto a draft was not refused: {body}");
assert!(
body.contains("already has a draft"),
"unexpected refusal: {body}"
);
// The whole deploy rolled back: the source is still the live head, and the
// draft at the destination is untouched.
let head: Value = client
.get(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/get/p/u/test-user/mvtaken_a"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.json()
.await?;
assert_eq!(head["hash"], HEAD_HASH, "source was replaced: {head}");
assert_eq!(head["archived"], false, "source was archived: {head}");
let draft: Value = client
.get(format!("http://localhost:{port}/api/w/test-workspace/drafts/get_own/script/u/test-user/mvtaken_b"))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.json()
.await?;
assert_eq!(
draft["value"]["summary"], "B",
"destination draft changed: {draft}"
);
Ok(())
}
/// A legacy (ownerless) draft occupies its path too: a deploy there deletes it together
/// with the caller's own row, so a move that parks a second draft beside it would discard
/// edits the caller never saw. Only an admin can clear it, so the refusal says so.
#[sqlx::test(fixtures("base", "drafts_move_taken"))]
async fn test_draft_move_refuses_a_legacy_destination(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/mvtaken_b"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": "u/test-user/mvtaken_legacy" }))
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status, 400, "move onto a legacy draft was allowed: {body}");
assert!(
body.contains("legacy workspace draft") && body.contains("workspace admin"),
"the refusal did not point at the one remedy: {body}"
);
// Both rows stayed where they were: the caller's own, and the legacy one the list
// synthesizes under the caller's name.
let list: Vec<Value> = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/list"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.json()
.await?;
let mut at = list
.iter()
.filter_map(|d| Some((d["kind"].as_str()?, d["path"].as_str()?)))
.filter(|(_, p)| p.starts_with("u/test-user/mvtaken_b") || p.ends_with("mvtaken_legacy"))
.collect::<Vec<_>>();
at.sort();
assert_eq!(
at,
vec![
("script", "u/test-user/mvtaken_b"),
("script", "u/test-user/mvtaken_legacy")
],
"{list:?}"
);
Ok(())
}
/// A classic app and a raw app deploy into the same table, so a draft-only move onto
/// the other kind's draft must be refused: deploying either path afterwards deletes
/// the caller's drafts of both kinds, taking the loser's item with it.
#[sqlx::test(fixtures("base", "drafts_move_taken"))]
async fn test_draft_move_refuses_the_other_app_kind(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/raw_app/u/test-user/mvtaken_raw"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": "u/test-user/mvtaken_app" }))
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(
status, 400,
"move onto a classic app draft was allowed: {body}"
);
assert!(
body.contains("already have a draft at 'u/test-user/mvtaken_app' (app)"),
"the refusal did not name the occupying kind: {body}"
);
// Both drafts are untouched.
let list: Vec<Value> = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/list"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.json()
.await?;
let mut at = list
.iter()
.filter(|d| matches!(d["kind"].as_str(), Some("app") | Some("raw_app")))
.filter_map(|d| Some((d["kind"].as_str()?, d["path"].as_str()?)))
.collect::<Vec<_>>();
at.sort();
assert_eq!(
at,
vec![
("app", "u/test-user/mvtaken_app"),
("raw_app", "u/test-user/mvtaken_raw")
],
"{list:?}"
);
Ok(())
}
/// Teammates' drafts of one item share its path by design, so another user's row is no
/// obstacle — except across the app pair, where the two kinds are different items on one
/// deployed path: deploying either strands the other, and deleting the app takes both.
#[sqlx::test(fixtures("base", "drafts_move_taken"))]
async fn test_draft_move_refuses_another_users_other_app_kind(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let move_to = |kind: &'static str, from: &'static str, to: &'static str| async move {
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/{kind}/{from}"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": to }))
.send()
.await?;
Ok::<_, anyhow::Error>((resp.status(), resp.text().await?))
};
let (status, body) = move_to(
"app",
"u/test-user/mvtaken_app",
"u/test-user/mvtaken_theirs",
)
.await?;
assert_eq!(
status, 400,
"a classic app was moved onto another user's raw app: {body}"
);
assert!(
body.contains("holds another user's raw app draft"),
"the refusal did not name the occupant: {body}"
);
// And the other direction, where the occupant reads as the classic kind.
let (status, body) = move_to(
"raw_app",
"u/test-user/mvtaken_raw",
"u/test-user/mvtaken_app_theirs",
)
.await?;
assert_eq!(
status, 400,
"a raw app was moved onto another user's classic app: {body}"
);
assert!(
body.contains("holds another user's app draft"),
"the refusal did not name the occupant: {body}"
);
// The same-kind case is the ordinary one: two users' drafts of one raw app.
let (status, body) = move_to(
"raw_app",
"u/test-user/mvtaken_raw",
"u/test-user/mvtaken_theirs",
)
.await?;
assert!(
status.is_success(),
"a raw app was refused beside another user's raw-app draft: {body}"
);
Ok(())
}
+43
View File
@@ -78,3 +78,46 @@ async fn test_draft_write_strips_nul(db: Pool<Postgres>) -> anyhow::Result<()> {
Ok(())
}
/// The lineage the server derives for `draft.base` comes from the same value: read before
/// the sanitizer, a NUL in it costs the draft its base, which then reads as up to date
/// whatever the head is.
#[sqlx::test(fixtures("drafts_nul"))]
async fn test_draft_base_is_derived_from_sanitized_value(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/dnul-ws");
let resp = authed(client().post(format!(
"{base}/drafts/update/script/u/dnul-admin/poisoned_base"
)))
.json(&json!({
"value": {
"summary": "s",
"path": "u/dnul-admin/poisoned_base",
"content": "x",
"parent_hash": "0000000000001b62\u{0}"
}
}))
.send()
.await?;
assert_eq!(
resp.status(),
200,
"save refused: {}",
resp.text().await.unwrap_or_default()
);
let stored: Option<String> = sqlx::query_scalar(
"SELECT base FROM draft WHERE workspace_id = 'dnul-ws' AND path = 'u/dnul-admin/poisoned_base'",
)
.fetch_one(&db)
.await?;
assert_eq!(
stored.as_deref(),
Some("0000000000001b62"),
"the NUL cost the draft its base: {stored:?}"
);
Ok(())
}
+587
View File
@@ -0,0 +1,587 @@
//! A draft save addressed to a path its item moved away from lands on the moved draft.
//!
//! A move carries every draft on the item to the new path and records where they
//! went. An editor left open across it still saves to the old path; the server puts
//! the save on the moved draft, keeps the path keys the move gave it, and names the
//! new path so the editor can follow. Without the record the save would plant a
//! phantom draft-only item at the old location.
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
/// Hex form of script hash 7030, the way the API takes a parent hash.
const HEAD_HASH: &str = "0000000000001b76";
async fn own_draft_value(port: u16, path: &str) -> anyhow::Result<Value> {
let draft: Value = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/get_own/script/{path}"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.json()
.await?;
Ok(draft["value"].clone())
}
async fn own_draft_paths(port: u16) -> anyhow::Result<Vec<String>> {
let list: Vec<Value> = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/list"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.json()
.await?;
Ok(list
.iter()
.filter(|d| d["kind"] == "script")
.filter_map(|d| d["path"].as_str().map(String::from))
.filter(|p| p.starts_with("u/test-user/follow_"))
.collect())
}
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_save_follows_a_rename(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client = reqwest::Client::new();
// Rename from Home: a redeploy of the deployed content at the new path that
// keeps the deployer's own draft, so the draft is carried rather than consumed.
let resp = client
.post(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/create"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"path": "u/test-user/follow_b",
"parent_hash": HEAD_HASH,
"summary": "A",
"description": "",
"content": "export function main() { return 1 }",
"language": "deno",
"schema": {},
"skip_draft_deletion": true
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "rename failed: {}", resp.text().await?);
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_b"]);
// A script draft's `path` is where deploying it lands, so it moves with the row.
assert_eq!(
own_draft_value(port, "u/test-user/follow_b").await?["path"],
"u/test-user/follow_b"
);
// The editor is still on the old path and writes that path back into the value.
let saved: Value = client
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/follow_a"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"value": {
"path": "u/test-user/follow_a",
"parent_hash": HEAD_HASH,
"summary": "A",
"content": "edited after the move"
}
}))
.send()
.await?
.json()
.await?;
assert_eq!(saved["status"], "saved", "save refused: {saved}");
assert_eq!(
saved["path"], "u/test-user/follow_b",
"save did not follow the row: {saved}"
);
// The write landed on the carried row; nothing reappeared at the old path.
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_b"]);
let draft = own_draft_value(port, "u/test-user/follow_b").await?;
assert_eq!(draft["content"], "edited after the move", "{draft}");
assert_eq!(draft["path"], "u/test-user/follow_b", "{draft}");
Ok(())
}
/// A draft-only move rewrites both path keys. The owner's open editor still carries
/// the typed path it had, which names neither the old nor the new path; the moved
/// draft's own keys have to win, or the save walks the item back.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_save_follows_a_draft_only_move(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client = reqwest::Client::new();
let resp = client
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/draft_store"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": "u/test-user/moved" }))
.send()
.await?;
assert!(
resp.status().is_success(),
"move failed: {}",
resp.text().await?
);
let saved: Value = client
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/draft_store"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"value": {
"path": "u/test-user/friendly",
"draft_path": "u/test-user/friendly",
"summary": "D",
"content": "edited after the move"
}
}))
.send()
.await?
.json()
.await?;
assert_eq!(saved["path"], "u/test-user/moved", "{saved}");
let draft = own_draft_value(port, "u/test-user/moved").await?;
assert_eq!(draft["content"], "edited after the move", "{draft}");
assert_eq!(draft["path"], "u/test-user/moved", "{draft}");
assert_eq!(draft["draft_path"], "u/test-user/moved", "{draft}");
Ok(())
}
/// Rename `from` to `to` the way Home does: redeploy the deployed content at the new
/// path, keeping the deployer's own draft so it is carried rather than consumed.
/// Returns the new head's hash.
async fn rename(port: u16, from_hash: &str, to: &str) -> anyhow::Result<String> {
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/create"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"path": to,
"parent_hash": from_hash,
"summary": "A",
"description": "",
"content": "export function main() { return 1 }",
"language": "deno",
"schema": {},
"skip_draft_deletion": true
}))
.send()
.await?;
let status = resp.status();
let hash = resp.text().await?;
assert_eq!(status, 201, "rename to {to} failed: {hash}");
Ok(hash)
}
/// Save the draft as an editor still bound to `url_path` would. Returns the path the
/// save landed at.
async fn save_at(port: u16, url_path: &str, content: &str) -> anyhow::Result<String> {
let saved: Value = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/{url_path}"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"value": { "path": url_path, "summary": "A", "content": content, "language": "deno" }
}))
.send()
.await?
.json()
.await?;
assert_eq!(saved["status"], "saved", "save refused: {saved}");
Ok(saved["path"].as_str().unwrap_or_default().to_string())
}
/// A record is kept to one hop, and a move back to the path it left ends it: both are
/// three statements whose order decides the answer.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_move_records_stay_one_hop(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let b = rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
let _c = rename(port, &b, "u/test-user/follow_c").await?;
assert_eq!(
save_at(port, "u/test-user/follow_a", "after two moves").await?,
"u/test-user/follow_c",
"a save at the first path did not reach the last"
);
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_c"]);
Ok(())
}
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_move_back_ends_the_record(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let b = rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
let _a = rename(port, &b, "u/test-user/follow_a").await?;
assert_eq!(
save_at(port, "u/test-user/follow_a", "after moving back").await?,
"u/test-user/follow_a",
"a save was routed off the path the item moved back to"
);
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_a"]);
Ok(())
}
/// A rename carries a teammate's row too: both its path keys follow, and the version
/// it forked from does not move. A restamp there would clear their out-of-date prompt
/// and let them deploy over the mover's version believing they were current.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_a_teammates_draft_follows_with_its_base(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
// Read from the pool: the teammate's row is another user's, and this asserts on
// `base`, which no endpoint exposes for someone else's draft.
let row: (String, String, Option<String>) = sqlx::query_as(
"SELECT value::jsonb ->> 'path', value::jsonb ->> 'draft_path', base
FROM draft WHERE workspace_id = 'test-workspace' AND typ = 'script'
AND email = 'test2@windmill.dev'",
)
.fetch_one(&db)
.await?;
assert_eq!(row.0, "u/test-user/follow_b", "typed path did not follow");
assert_eq!(row.1, "u/test-user/follow_b", "mirror did not follow");
assert_eq!(
row.2.as_deref(),
Some(HEAD_HASH),
"the teammate's base was restamped by someone else's rename"
);
Ok(())
}
/// An item move and then the owner's own move of what is left: the two records have
/// different scopes, so the owner's move has to extend the chain in its own scope or
/// a save addressed to the first path stops at the abandoned middle one.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_an_owner_move_extends_an_item_move(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
// Archiving the script at the new path leaves the carried draft as a draft-only
// item, which its owner can move through `/drafts/move`.
sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_b'")
.execute(&db)
.await?;
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_b"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": "u/test-user/follow_c" }))
.send()
.await?;
assert!(
resp.status().is_success(),
"move failed: {}",
resp.text().await?
);
assert_eq!(
save_at(port, "u/test-user/follow_a", "after both moves").await?,
"u/test-user/follow_c",
"a save at the first path stopped at the path the owner's move left"
);
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_c"]);
Ok(())
}
/// Redeploying at a path an owner's move routed away from ends that route: the live item
/// owns its path again, and its saves must not follow the draft that left.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_redeploy_at_a_routed_path_ends_the_route(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// `move_draft` ignores archived rows, so an archived script's draft can be moved away.
sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_a'")
.execute(&db)
.await?;
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_a"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": "u/test-user/follow_b" }))
.send()
.await?;
assert!(
resp.status().is_success(),
"move failed: {}",
resp.text().await?
);
// Unarchiving redeploys at the same path, with the archived version as parent.
rename(port, HEAD_HASH, "u/test-user/follow_a").await?;
assert_eq!(
save_at(port, "u/test-user/follow_a", "for the live script").await?,
"u/test-user/follow_a",
"a save for the redeployed script followed the draft that moved away"
);
Ok(())
}
/// A draft written before the NUL sanitizer still has to follow a move: its path keys are
/// what a deploy of it would land on, so the carry rewrites them, sanitizing the value it
/// could not otherwise parse.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_a_poisoned_draft_follows_a_rename(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// The teammate's row, rewritten the way a pre-sanitizer client left one: a real NUL
// escape in the content, both path keys naming the path the item is about to leave.
sqlx::query(
r#"UPDATE draft SET value = '{"path": "u/test-user/follow_a", "draft_path": "u/test-user/follow_a",
"parent_hash": "0000000000001b76", "summary": "A", "content": "a\u0000b"}'
WHERE email = 'test2@windmill.dev'"#,
)
.execute(&db)
.await?;
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
let row: (String, String, String) = sqlx::query_as(
"SELECT value::jsonb ->> 'path', value::jsonb ->> 'draft_path', value::jsonb ->> 'content'
FROM draft WHERE email = 'test2@windmill.dev'",
)
.fetch_one(&db)
.await?;
assert_eq!(row.0, "u/test-user/follow_b", "typed path did not follow");
assert_eq!(row.1, "u/test-user/follow_b", "mirror did not follow");
assert_eq!(row.2, "ab", "the NUL survived the rewrite");
Ok(())
}
/// A discard whose answer is lost is retried, and the retry finds nothing to delete. It
/// still has to name where the item went: the editor asking is on the path it left, and a
/// reload there lands on nothing.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_a_retried_discard_still_names_the_destination(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client = reqwest::Client::new();
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
let discard = |token: &'static str| {
let client = client.clone();
async move {
client
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/follow_a"
))
.header("Authorization", format!("Bearer {token}"))
.json(&json!({ "value": null }))
.send()
.await?
.json::<Value>()
.await
}
};
let first = discard("SECRET_TOKEN").await?;
assert_eq!(first["status"], "saved", "{first}");
assert_eq!(first["path"], "u/test-user/follow_b", "{first}");
let retry = discard("SECRET_TOKEN").await?;
assert_eq!(
retry["status"], "saved",
"the retry was not idempotent: {retry}"
);
assert_eq!(retry["path"], "u/test-user/follow_b", "{retry}");
// A third user has no draft on this item, so their discard deletes nothing and the
// destination is a move record and nothing else: it is answered only to a caller who
// can read that path.
let other = discard("SECRET_TOKEN_3").await?;
assert_eq!(other["status"], "saved", "{other}");
assert_eq!(
other["path"],
Value::Null,
"a user who cannot read the destination was told where the item went: {other}"
);
Ok(())
}
/// The legacy workspace-level row is carried by a rename like any other draft, and the
/// record that routes saves to it covers every caller — so discarding it from a page that
/// still names the old path has to reach it where it went.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_a_legacy_discard_follows_a_rename(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
sqlx::query(
r#"INSERT INTO draft (workspace_id, path, typ, value, email)
VALUES ('test-workspace', 'u/test-user/follow_a', 'script',
'{"path": "u/test-user/follow_a", "summary": "legacy", "content": "x"}', NULL)"#,
)
.execute(&db)
.await?;
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/follow_a"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "value": null, "legacy": true }))
.send()
.await?;
assert!(
resp.status().is_success(),
"discard failed: {}",
resp.text().await?
);
let left: i64 = sqlx::query_scalar(
"SELECT count(*) FROM draft WHERE workspace_id = 'test-workspace' AND email IS NULL",
)
.fetch_one(&db)
.await?;
assert_eq!(
left, 0,
"the legacy draft survived a discard aimed at its old path"
);
Ok(())
}
/// Deploying a draft that a move carried off an archived script: its parent is still the
/// version at the old path, so the deploy renames from there and carries what is left over
/// — onto the very draft being deployed. That row is this deploy's own, not an item in its
/// way, or the deploy is refused and every retry refuses again.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_a_moved_draft_deploys_at_its_new_path(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_a'")
.execute(&db)
.await?;
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_a"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": "u/test-user/follow_b" }))
.send()
.await?;
assert!(
resp.status().is_success(),
"move failed: {}",
resp.text().await?
);
// Deploy it where it now lives, still parented on the archived version it forked from.
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/create"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"path": "u/test-user/follow_b",
"parent_hash": HEAD_HASH,
"summary": "A",
"description": "",
"content": "export function main() { return 2 }",
"language": "deno",
"schema": {}
}))
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status, 201, "the moved draft could not be deployed: {body}");
Ok(())
}
/// A route is only as good as the item it points at: when an unrelated item claims the
/// destination, a save still addressed to the old path must stay where it is rather than
/// land on that item's draft.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_a_reused_destination_ends_the_route(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_a'")
.execute(&db)
.await?;
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_a"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": "u/test-user/follow_b" }))
.send()
.await?;
assert!(
resp.status().is_success(),
"move failed: {}",
resp.text().await?
);
// Someone else's item takes the destination, and the moved draft goes with the
// deploy that consumes it.
sqlx::query("DELETE FROM draft WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/follow_b'")
.execute(&db)
.await?;
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/create"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"path": "u/test-user/follow_b",
"summary": "unrelated",
"description": "",
"content": "export function main() { return 3 }",
"language": "deno",
"schema": {}
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "create failed: {}", resp.text().await?);
assert_eq!(
save_at(port, "u/test-user/follow_a", "after the destination was reused").await?,
"u/test-user/follow_a",
"a save was routed onto the item that now owns the destination"
);
Ok(())
}
+16
View File
@@ -0,0 +1,16 @@
-- Fixture for the two-path-key move test.
--
-- Two draft-only scripts owned by test-user, both parked at a generated storage
-- key the way a session-created draft is. One carries the `draft_path` mirror the
-- session editor writes while the typed path differs from that key; the other
-- carries no mirror at all, which is what a plain draft looks like.
INSERT INTO draft (workspace_id, path, typ, value, email) VALUES
('test-workspace', 'u/test-user/draft_mirror', 'script',
'{"path": "u/test-user/friendly", "draft_path": "u/test-user/friendly",
"content": "x", "language": "bun", "summary": "S"}',
'test@windmill.dev'),
('test-workspace', 'u/test-user/draft_plain', 'script',
'{"path": "u/test-user/draft_plain",
"content": "x", "language": "bun", "summary": "S"}',
'test@windmill.dev');
+37
View File
@@ -0,0 +1,37 @@
-- Fixture for refusing a rename onto a path a draft already occupies.
--
-- A deployed script at `u/test-user/mvtaken_a` (hash 7010 = 0x1b62), and a
-- never-deployed draft of test-user's own at `u/test-user/mvtaken_b`, the path
-- the rename will target. Nothing deployed lives at the target, so only the
-- draft can refuse the move.
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by,
schema, summary, description, lock, extra_perms)
VALUES ('test-workspace', 7010, 'u/test-user/mvtaken_a',
'export function main() { return 1 }',
'deno', 'script', 'test-user', '{}', 'A', '', '', '{}');
INSERT INTO draft (workspace_id, path, typ, value, email)
VALUES ('test-workspace', 'u/test-user/mvtaken_b', 'script',
'{"path": "u/test-user/mvtaken_b", "summary": "B", "content": ""}',
'test@windmill.dev');
-- An ownerless (pre per-user drafts) row at a third path: a deploy there deletes it
-- together with the caller's, so it occupies the path just as an own draft does.
INSERT INTO draft (workspace_id, path, typ, value, email)
VALUES ('test-workspace', 'u/test-user/mvtaken_legacy', 'script',
'{"path": "u/test-user/mvtaken_legacy", "summary": "legacy", "content": ""}',
NULL);
-- A draft-only classic app and a draft-only raw app of the same owner. They share
-- the `app` table, so one occupies the other's path. The third is another user's raw
-- app: a different item at its own path, not a second copy of anyone's.
INSERT INTO draft (workspace_id, path, typ, value, email) VALUES
('test-workspace', 'u/test-user/mvtaken_app', 'app',
'{"summary": "classic", "value": {}}', 'test@windmill.dev'),
('test-workspace', 'u/test-user/mvtaken_raw', 'raw_app',
'{"summary": "raw", "files": {}}', 'test@windmill.dev'),
('test-workspace', 'u/test-user/mvtaken_theirs', 'raw_app',
'{"summary": "theirs", "files": {}}', 'test2@windmill.dev'),
('test-workspace', 'u/test-user/mvtaken_app_theirs', 'app',
'{"summary": "theirs", "value": {}}', 'test2@windmill.dev');
+29
View File
@@ -0,0 +1,29 @@
-- Fixture for saving a draft to the path its item moved away from.
--
-- A deployed script at `u/test-user/follow_a` (hash 7030 = 0x1b76) with the
-- deployer's own draft on it. The test renames the script and then saves the
-- draft from an editor still bound to the old path.
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by,
schema, summary, description, lock, extra_perms)
VALUES ('test-workspace', 7030, 'u/test-user/follow_a',
'export function main() { return 1 }',
'deno', 'script', 'test-user', '{}', 'A', '', '', '{}');
INSERT INTO draft (workspace_id, path, typ, value, email, base)
VALUES ('test-workspace', 'u/test-user/follow_a', 'script',
'{"path": "u/test-user/follow_a", "parent_hash": "0000000000001b76", "summary": "A", "content": "draft"}',
'test@windmill.dev', '0000000000001b76');
-- A draft-only script parked at a generated storage key, its typed path elsewhere.
INSERT INTO draft (workspace_id, path, typ, value, email)
VALUES ('test-workspace', 'u/test-user/draft_store', 'script',
'{"path": "u/test-user/friendly", "draft_path": "u/test-user/friendly", "summary": "D", "content": "draft"}',
'test@windmill.dev');
-- A teammate's draft on the same deployed script, forked from the same head. The
-- rename must carry it too, without touching the version it forked from.
INSERT INTO draft (workspace_id, path, typ, value, email, base)
VALUES ('test-workspace', 'u/test-user/follow_a', 'script',
'{"path": "u/test-user/follow_a", "draft_path": "u/test-user/follow_a", "parent_hash": "0000000000001b76", "summary": "A", "content": "teammate draft"}',
'test2@windmill.dev', '0000000000001b76');
+42 -6
View File
@@ -51,7 +51,10 @@ use windmill_common::{
jobs::JobPayload,
schedule::Schedule,
triggers::MovedNativeTrigger,
utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, RunnableKind, StripPath},
utils::{
http_get_from_hub, not_found_if_none, paginate, paginate_optional, Pagination,
RunnableKind, StripPath,
},
};
use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap;
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
@@ -747,6 +750,14 @@ async fn create_flow(
.execute(&mut *tx)
.await?;
}
windmill_common::user_drafts::clear_draft_moves_from(
&mut tx,
&w_id,
&[UserDraftItemKind::Flow],
&nf.path,
None,
)
.await?;
audit_log(
&mut *tx,
@@ -915,25 +926,36 @@ pub struct FlowVersion {
pub created_at: chrono::DateTime<chrono::Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deployment_msg: Option<String>,
/// Who deployed this version — the diff's version picker names them so a reader
/// can tell their own deploys from a teammate's.
#[serde(skip_serializing_if = "Option::is_none")]
pub created_by: Option<String>,
}
async fn get_flow_history(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<FlowVersion>> {
let path = path.to_path();
check_scopes(&authed, || format!("flows:read:{}", path))?;
// Unasked-for, this listing stays whole: the history panels, the restart picker and
// the CLI all read it without paging. The diff picker asks for a page.
let (per_page, offset) = paginate_optional(pagination);
let mut tx = user_db.begin(&authed).await?;
let flows = sqlx::query_as!(
FlowVersion,
"SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version
"SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version
LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version
WHERE flow_version.path = $1 AND flow_version.workspace_id = $2
ORDER BY flow_version.created_at DESC",
WHERE flow_version.path = $1 AND flow_version.workspace_id = $2
ORDER BY flow_version.created_at DESC
LIMIT $3 OFFSET $4",
path,
w_id
w_id,
per_page,
offset,
)
.fetch_all(&mut *tx)
.await?;
@@ -953,7 +975,7 @@ async fn get_latest_version(
let version = sqlx::query_as!(
FlowVersion,
"SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version
"SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version
LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version
WHERE flow_version.path = $1 AND flow_version.workspace_id = $2
ORDER BY flow_version.created_at DESC",
@@ -1438,6 +1460,20 @@ async fn update_flow(
.await?;
}
if is_new_path {
// Everything left at the old path is a draft this deploy didn't consume
// — teammates' rows, and the deployer's own when the caller asked us to
// keep it. Carry them rather than strand them.
windmill_common::user_drafts::move_drafts_for_path(
&mut tx,
&w_id,
&[UserDraftItemKind::Flow],
flow_path,
&nf.path,
)
.await?;
}
audit_log(
&mut *tx,
&authed,
@@ -407,9 +407,18 @@ async fn test_fork_repoints_unresolvable_identities(db: Pool<Postgres>) -> anyho
text("SELECT value->'policy'->>'on_behalf_of' FROM draft WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/stranger' AND typ = 'raw_app'").await?,
"u/test-user"
);
// `clone_drafts` strips a NUL escape as it copies, so the row reaches the fork
// parseable and the repoint below reaches it like any other draft's. The rule this
// guards is that the fork completes and no identity naming nobody survives it; the
// skip only ever existed because `to_jsonb` raises on a value still holding one.
assert_eq!(
text("SELECT CASE WHEN strpos(value::text, 'u/test-user-2') > 0 THEN 'kept' ELSE 'rewritten' END FROM draft WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/nul'").await?,
"kept"
"rewritten"
);
// And it arrives without the poison that made it a special case.
assert_eq!(
text("SELECT CASE WHEN position(chr(92) || 'u0000' in value::text) > 0 THEN 'poisoned' ELSE 'clean' END FROM draft WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/nul'").await?,
"clean"
);
assert_eq!(
text("SELECT value->>'permissioned_as' FROM draft WHERE workspace_id = 'wm-fork-repoint' AND typ = 'trigger_websocket'").await?,
+52 -16
View File
@@ -55,7 +55,7 @@ use windmill_common::{
min_version_supports_runnable_settings_v0, RunnableSettings, RunnableSettingsTrait,
},
scripts::{hash_script, ScriptRunnableSettingsHandle, ScriptRunnableSettingsInline},
utils::{paginate_without_limits, WarnAfterExt},
utils::{paginate_optional, paginate_without_limits, WarnAfterExt},
worker::CLOUD_HOSTED,
};
use windmill_object_store::upload_artifact_to_store;
@@ -2312,6 +2312,20 @@ async fn create_script_internal<'c>(
.await?;
}
if p_path != &ns.path {
// Everything left at the old path is a draft this deploy didn't
// consume — teammates' rows, and the deployer's own when the caller
// asked us to keep it. Carry them rather than strand them.
windmill_common::user_drafts::move_drafts_for_path(
&mut tx,
&w_id,
&[UserDraftItemKind::Script],
p_path,
&ns.path,
)
.await?;
}
sqlx::query!(
"UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE",
ns.path,
@@ -2389,19 +2403,32 @@ async fn create_script_internal<'c>(
tx = push_scheduled_job(&db, tx, &schedule, None, None).await?;
}
}
} else if !skip_draft_deletion {
// See the matching branch above — only wipe the deployer's own
// draft (plus the legacy NULL-email row).
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' \
AND (email = $3 OR email IS NULL)",
ns.path,
&w_id,
&authed.email,
)
.execute(&mut *tx)
.await?;
} else {
if !skip_draft_deletion {
// See the matching branch above — only wipe the deployer's own
// draft (plus the legacy NULL-email row).
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' \
AND (email = $3 OR email IS NULL)",
ns.path,
&w_id,
&authed.email,
)
.execute(&mut *tx)
.await?;
}
}
// Every deploy, not only a new script: an archived script's draft can be moved away
// (`move_draft` ignores archived rows), and unarchiving redeploys at the same path,
// where a route left behind would send the live script's saves to the moved draft.
windmill_common::user_drafts::clear_draft_moves_from(
&mut tx,
&w_id,
&[UserDraftItemKind::Script],
&ns.path,
p_path_opt.as_deref(),
)
.await?;
if p_hashes.is_some() && !p_hashes.unwrap().is_empty() {
audit_log(
&mut *tx,
@@ -3061,17 +3088,24 @@ async fn get_script_history(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<ScriptHistory>> {
let path = path.to_path();
check_scopes(&authed, || format!("scripts:read:{}", path))?;
// Unasked-for, this listing stays whole: the deployment-history panels, the restart
// picker and the CLI all read it without paging. The diff picker asks for a page.
let (per_page, offset) = paginate_optional(pagination);
let mut tx = user_db.begin(&authed).await?;
let query_result = sqlx::query!(
"SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at
"SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by
FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash
WHERE s.workspace_id = $1 AND s.path = $2
ORDER by s.created_at DESC",
ORDER by s.created_at DESC
LIMIT $3 OFFSET $4",
w_id,
path,
per_page,
offset,
)
.fetch_all(&mut *tx)
.await?;
@@ -3083,6 +3117,7 @@ async fn get_script_history(
script_hash: ScriptHash(row.hash),
deployment_msg: row.deployment_msg,
created_at: Some(row.created_at),
created_by: Some(row.created_by),
})
.collect();
return Ok(Json(result));
@@ -3097,7 +3132,7 @@ async fn get_latest_version(
check_scopes(&authed, || format!("scripts:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
let row_o = sqlx::query!(
"SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at
"SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by
FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash
WHERE s.workspace_id = $1 AND s.path = $2
ORDER by s.created_at DESC LIMIT 1",
@@ -3113,6 +3148,7 @@ async fn get_latest_version(
script_hash: ScriptHash(row.hash),
deployment_msg: row.deployment_msg,
created_at: Some(row.created_at),
created_by: Some(row.created_by),
};
return Ok(Json(Some(result)));
} else {
@@ -7961,14 +7961,32 @@ async fn clone_drafts(
// filtered like `clone_scripts`: the address the draft still carries re-derives the
// clone's own principal at deploy time, which is the more accurate answer of the two.
sqlx::query!(
"INSERT INTO draft (workspace_id, path, typ, value, created_at, email)
// A script hash is content-addressed and copied as-is, so a script draft's base
// still names a version the clone has. `clone_flows` / `clone_apps` mint new ids,
// so those drafts arrive with no base (staleness falls back to the timestamps),
// lineage field included, or the next autosave would re-derive the source id.
//
// `clean` is `strip_json_nul`'s parity rule in SQL, so a pre-sanitizer U+0000
// escape cannot abort the clone on `to_jsonb` or arrive with its principal
// unstripped: escaped backslashes park on chr(1) (lossless, a `json` value's text
// cannot hold a raw control byte) so only a real NUL is removed, and chr(92)
// spells the backslash so no escape sequence reaches this source file.
r#"INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base)
SELECT $2, path, typ,
CASE WHEN typ IN ('script', 'flow')
THEN to_json(to_jsonb(value) - 'on_behalf_of')
ELSE value END,
created_at, email
FROM draft
WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)",
to_json(
CASE WHEN typ IN ('script', 'flow') THEN clean - 'on_behalf_of' ELSE clean END
- CASE WHEN typ = 'flow' THEN 'version_id'
WHEN typ IN ('app', 'raw_app') THEN 'parent_version'
ELSE '' END
),
created_at, email,
CASE WHEN typ = 'script' THEN base END
FROM (
SELECT d.path, d.typ, d.created_at, d.email, d.base,
replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean
FROM draft d
WHERE d.workspace_id = $1 AND (d.email = $3 OR d.email IS NULL)
) s"#,
source_workspace_id,
target_workspace_id,
authed_email,
@@ -382,6 +382,13 @@ pub(crate) async fn change_workspace_id(
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE draft_move SET workspace_id = $1 WHERE workspace_id = $2",
&rw.new_id,
&old_id
)
.execute(&mut *tx)
.await?;
info!("Updating favorite table");
sqlx::query!(
+86 -6
View File
@@ -10392,8 +10392,49 @@ paths:
current_timestamp:
type: string
format: date-time
path:
type: string
description: "`saved` only, upsert or delete: where the write landed. Differs from the URL path when the item had moved away from it; the editor follows it there. Absent when a delete found nothing to remove and the caller cannot read the path it moved to."
required: [status, current_timestamp]
/w/{workspace}/drafts/move/{kind}/{path}:
post:
summary: move the current user's draft-only item to another path
description: Relocates the authed user's own draft row, along with both path keys inside its value (the typed path and the mirror the editors keep beside it). Only for draft-only items — a deployed item must be moved through its own deploy endpoint, which carries every draft with it. Restricted to script, flow, app and raw_app; any other kind is rejected with 400, because only these keep their deploy target where this endpoint rewrites it.
operationId: moveDraft
tags:
- draft
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: kind
in: path
required: true
description: script, flow, app or raw_app only.
schema:
type: string
enum: [script, flow, app, raw_app]
- $ref: "#/components/parameters/ScriptPath"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
new_path:
type: string
summary:
type: string
description: Also restate the draft's summary.
required: [new_path]
responses:
"200":
description: move result
content:
text/plain:
schema:
type: string
/w/{workspace}/drafts/migrate_legacy/{kind}/{path}:
post:
summary: resolve a legacy (workspace-level) draft (admin only)
@@ -10904,6 +10945,8 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
responses:
"200":
description: script history
@@ -12309,6 +12352,8 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
tags:
- flow
responses:
@@ -14187,6 +14232,8 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
responses:
"200":
description: app history
@@ -14454,9 +14501,9 @@ paths:
"200":
description: app updated
content:
text/plain:
application/json:
schema:
type: string
$ref: "#/components/schemas/AppDeployed"
/w/{workspace}/apps/create_raw_source:
post:
@@ -14596,9 +14643,9 @@ paths:
"200":
description: app updated
content:
text/plain:
application/json:
schema:
type: string
$ref: "#/components/schemas/AppDeployed"
/w/{workspace}/apps/update_raw/{path}:
post:
@@ -14652,9 +14699,9 @@ paths:
"200":
description: app updated
content:
text/plain:
application/json:
schema:
type: string
$ref: "#/components/schemas/AppDeployed"
/w/{workspace}/apps/custom_path_exists/{custom_path}:
get:
@@ -27702,6 +27749,13 @@ components:
draft_saved_at:
type: string
format: date-time
draft_base:
type: string
description: |
The deployed version the draft forked from, as text whatever the
kind (script hash, flow version id, app version id). Compare to the
deployed head to tell a draft that is behind. Absent when there is
no draft or it was never forked from a deploy.
no_deployed:
type: boolean
draft:
@@ -29597,6 +29651,11 @@ components:
type: string
deployment_msg:
type: string
created_at:
type: string
format: date-time
created_by:
type: string
required:
- script_hash
@@ -35493,6 +35552,20 @@ components:
- raw_app
AppDeployed:
type: object
description: "What a deploy of an existing app answers with. `version` is the one this call wrote, which is what an editor pins as the fork base of the draft it starts next: reading the head back afterwards cannot tell it from a deploy that landed beside it. A metadata-only update writes none and reports the head it kept."
properties:
path:
type: string
description: Where the app now lives, which differs from the request path on a rename.
version:
type: integer
format: int64
required:
- path
- version
AppHistory:
type: object
properties:
@@ -35500,6 +35573,11 @@ components:
type: integer
deployment_msg:
type: string
created_at:
type: string
format: date-time
created_by:
type: string
required:
- version
@@ -35569,6 +35647,8 @@ components:
format: date-time
deployment_msg:
type: string
created_by:
type: string
required:
- id
- created_at
+91 -17
View File
@@ -69,8 +69,8 @@ use windmill_common::{
user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay},
users::username_to_permissioned_as,
utils::{
http_get_from_hub, not_found_if_none, paginate, query_elems_from_hub, require_admin,
strip_json_nul, Pagination, RunnableKind, StripPath,
http_get_from_hub, not_found_if_none, paginate, paginate_optional,
query_elems_from_hub, require_admin, strip_json_nul, Pagination, RunnableKind, StripPath,
},
variables::{build_crypt, build_crypt_with_key_suffix, encrypt},
worker::{to_raw_value, CLOUD_HOSTED},
@@ -274,6 +274,12 @@ pub struct AppHistory {
pub version: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub deployment_msg: Option<String>,
/// Who deployed this version, and when — the diff's version picker names them so
/// a reader can tell their own deploys from a teammate's.
#[serde(skip_serializing_if = "Option::is_none")]
pub created_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Deserialize)]
@@ -567,6 +573,17 @@ pub struct CreateApp {
pub skip_draft_deletion: Option<bool>,
}
/// What a deploy of an existing app answers with. `version` is the one this call wrote,
/// which is what an editor pins as the fork base of the draft it starts next: reading the
/// head back afterwards cannot tell it from a deploy that landed beside it. A
/// metadata-only update writes none and reports the head it kept.
#[derive(Serialize)]
pub struct AppDeployed {
/// Where the app now lives, which differs from the request path on a rename.
pub path: String,
pub version: i64,
}
#[derive(Serialize, Deserialize)]
pub struct EditApp {
pub path: Option<String>,
@@ -1220,18 +1237,42 @@ async fn get_app_history(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<AppHistory>> {
let path = path.to_path();
check_scopes(&authed, || format!("apps:read:{}", &path))?;
// Unasked-for, this listing stays whole: the deployment-history panel reads it
// without paging. The diff picker asks for a page.
let (per_page, offset) = paginate_optional(pagination);
let mut tx = user_db.begin(&authed).await?;
// Newest first in deployed order, which is a version's position in `app.versions` and
// not its `created_at`: the latter is the deploying transaction's start time, so two
// that overlap can carry it in the opposite order from the one they landed in. A row
// outside the array never sat in that sequence, so it sorts after the ones that did.
// Paging happens before the metadata joins, so a page costs its own rows.
let query_result = sqlx::query!(
"SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg
FROM app a LEFT JOIN app_version av ON a.id = av.app_id LEFT JOIN deployment_metadata dm ON av.id = dm.app_version
"SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg,
av.created_by as created_by, av.created_at as created_at
FROM app a
JOIN LATERAL (
SELECT av2.id, COALESCE(v.ord, 0) AS ord
FROM app_version av2
LEFT JOIN unnest(a.versions) WITH ORDINALITY AS v(id, ord) ON v.id = av2.id
WHERE av2.app_id = a.id
ORDER BY ord DESC, av2.id DESC
LIMIT $3 OFFSET $4
) page ON TRUE
JOIN app_version av ON av.id = page.id
LEFT JOIN deployment_metadata dm ON av.id = dm.app_version
WHERE a.workspace_id = $1 AND a.path = $2
ORDER BY created_at DESC",
ORDER BY page.ord DESC",
w_id,
path,
).fetch_all(&mut *tx).await?;
per_page,
offset,
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
let result: Vec<AppHistory> = query_result
@@ -1240,6 +1281,8 @@ async fn get_app_history(
app_id: row.app_id,
version: row.version_id,
deployment_msg: row.deployment_msg,
created_by: Some(row.created_by),
created_at: Some(row.created_at),
})
.collect();
return Ok(Json(result));
@@ -1253,14 +1296,22 @@ async fn get_latest_version(
let path = path.to_path();
check_scopes(&authed, || format!("apps:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
// The head is the tail of `app.versions` — the version the runtime serves. Deploys
// append to it under the app row's lock, whereas `app_version.created_at` is the
// deploying transaction's start time, so two that overlap can carry it in either
// order and the newest timestamp is then not the one that landed last.
let row = sqlx::query!(
"SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg
FROM app a LEFT JOIN app_version av ON a.id = av.app_id LEFT JOIN deployment_metadata dm ON av.id = dm.app_version
WHERE a.workspace_id = $1 AND a.path = $2
ORDER BY created_at DESC",
"SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg,
av.created_by as created_by, av.created_at as created_at
FROM app a JOIN app_version av
ON av.id = a.versions[array_upper(a.versions, 1)] AND av.app_id = a.id
LEFT JOIN deployment_metadata dm ON av.id = dm.app_version
WHERE a.workspace_id = $1 AND a.path = $2",
w_id,
path,
).fetch_optional(&mut *tx).await?;
)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
if let Some(row) = row {
@@ -1268,6 +1319,8 @@ async fn get_latest_version(
app_id: row.app_id,
version: row.version_id,
deployment_msg: row.deployment_msg,
created_by: Some(row.created_by),
created_at: Some(row.created_at),
};
return Ok(Json(Some(result)));
@@ -2591,6 +2644,14 @@ async fn create_app_internal<'a>(
.execute(&mut *tx)
.await?;
}
windmill_common::user_drafts::clear_draft_moves_from(
&mut tx,
&w_id,
&[UserDraftItemKind::App, UserDraftItemKind::RawApp],
&app.path,
None,
)
.await?;
let id = sqlx::query_scalar!(
"INSERT INTO app
(workspace_id, path, summary, policy, versions, custom_path, labels)
@@ -2947,7 +3008,7 @@ async fn update_app(
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(ns): Json<EditApp>,
) -> Result<String> {
) -> JsonResult<AppDeployed> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot update apps for security reasons".to_string(),
@@ -2986,7 +3047,7 @@ async fn update_app(
},
);
Ok(format!("app {} updated (npath: {:?})", opath, npath))
Ok(Json(AppDeployed { path: npath, version: v_id }))
}
/// Deploy a raw app from its sources, compiling them on a worker. `update_raw`
@@ -3000,7 +3061,7 @@ async fn update_app_raw_source(
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(ns): Json<EditApp>,
) -> Result<String> {
) -> JsonResult<AppDeployed> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot update apps for security reasons".to_string(),
@@ -3098,7 +3159,7 @@ async fn update_app_raw_source(
},
);
Ok(format!("app {} updated (npath: {:?})", opath, npath))
Ok(Json(AppDeployed { path: npath, version: v_id }))
}
/// Whether the caller may create an app at `path` — asked of the database rather
@@ -3324,7 +3385,7 @@ async fn update_app_raw<'a>(
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
multipart: Multipart,
) -> Result<String> {
) -> JsonResult<AppDeployed> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot update apps for security reasons".to_string(),
@@ -3372,7 +3433,7 @@ async fn update_app_raw<'a>(
},
);
Ok(format!("app {} updated (npath: {:?})", opath, npath))
Ok(Json(AppDeployed { path: npath, version: v_id }))
}
// async fn create_app_internal<'a>(
// authed: ApiAuthed,
@@ -3749,6 +3810,19 @@ async fn update_app_internal<'a>(
.execute(&mut *tx)
.await?;
}
if npath != path {
// Everything left at the old path is a draft this deploy didn't consume
// — teammates' rows, and the deployer's own when the caller asked us to
// keep it. Carry them rather than strand them.
windmill_common::user_drafts::move_drafts_for_path(
&mut tx,
&w_id,
&[UserDraftItemKind::App, UserDraftItemKind::RawApp],
path,
&npath,
)
.await?;
}
audit_log(
&mut *tx,
&authed,
+468 -22
View File
@@ -19,7 +19,7 @@ use windmill_common::{
error::{Error, Result},
user_drafts::{DraftUserRef, UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX},
users::resolve_username_to_email,
utils::strip_json_nul,
utils::{check_proper_path, strip_json_nul},
variables::{build_crypt, encrypt},
};
@@ -29,6 +29,7 @@ pub fn workspaced_service() -> Router {
.route("/get/{kind}/{*path}", get(get_draft_for_user))
.route("/get_own/{kind}/{*path}", get(get_own_draft))
.route("/update/{kind}/{*path}", post(update_draft))
.route("/move/{kind}/{*path}", post(move_draft))
.route("/migrate_legacy/{kind}/{*path}", post(migrate_legacy_draft))
}
@@ -320,12 +321,71 @@ pub struct SaveDraftResponse {
/// On `saved`: when the change was applied (client remembers it as the
/// next `last_sync`). On `conflict`: the existing row's `created_at`.
pub current_timestamp: chrono::DateTime<chrono::Utc>,
/// `saved` only: where the write landed. Differs from the URL path when the item
/// had moved away from it; the editor follows it there. Absent when a delete found
/// nothing to remove and the caller cannot read the path it moved to.
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
/// Apply the current user's draft at (workspace, kind, path): non-null `value`
/// upserts, `null` (or omitted) deletes. Either way, when the existing row is
/// newer than `last_sync` (and `force` is false) the op is skipped and the
/// response is `status = conflict` + the server's current timestamp.
/// The version a draft forked from, as the editors write it into `draft.value`.
/// Each kind names it differently and only one is ever set.
#[derive(Deserialize)]
struct DraftBaseVersion {
/// Scripts: hex-encoded script hash.
#[serde(default)]
parent_hash: Option<String>,
/// Flows: `flow_version.id`.
#[serde(default)]
version_id: Option<i64>,
/// Apps / raw apps: `app_version.id`.
#[serde(default)]
parent_version: Option<i64>,
}
impl DraftBaseVersion {
/// The base as the `draft.base` column stores it: one opaque text id whatever
/// the kind, so a reader compares it to the head without knowing the kind's
/// own field name or type.
fn as_text(&self, kind: UserDraftItemKind) -> Option<String> {
use UserDraftItemKind::*;
match kind {
Script => self.parent_hash.clone(),
Flow => self.version_id.map(|v| v.to_string()),
_ => self.parent_version.map(|v| v.to_string()),
}
}
}
/// The version this draft forked from, or `None` when it has none — a kind
/// that keeps no lineage, a malformed payload, or a draft that was never forked
/// from a deploy. Pure: no queries.
fn draft_lineage(kind: UserDraftItemKind, value: &str) -> Option<DraftBaseVersion> {
use UserDraftItemKind::*;
if !matches!(kind, Script | Flow | App | RawApp) {
return None;
}
let base = serde_json::from_str::<DraftBaseVersion>(value).ok()?;
let has_base = match kind {
Script => base
.parent_hash
.as_deref()
.and_then(|h| windmill_common::scripts::to_i64(h).ok())
.is_some(),
Flow => base.version_id.is_some(),
_ => base.parent_version.is_some(),
};
has_base.then_some(base)
}
/// Apply the current user's draft: non-null `value` upserts, `null` (or
/// omitted) deletes. Either way, when the existing row is newer than
/// `last_sync` (and `force` is false) the op is skipped and the response is
/// `status = conflict` + the server's current timestamp.
///
/// A save addressed to a path its item moved away from lands where the move took
/// the drafts (`draft_move`), unless the caller still has a draft of their own at
/// that path. The response names where it landed.
async fn update_draft(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -334,7 +394,7 @@ async fn update_draft(
Json(req): Json<SaveDraftRequest>,
) -> Result<Json<SaveDraftResponse>> {
let email = &authed.email;
let path = path.to_path();
let url_path = path.to_path();
// Saving a draft requires write permission on the underlying path. Deleting
// (discarding) one's OWN draft does not: the email-scoped row belongs to the
// authed user, so they can always discard it even after losing write access
@@ -343,11 +403,68 @@ async fn update_draft(
// touch the caller's own row. Legacy (NULL-email) rows aren't owned by anyone
// — they keep the write gate.
let is_own_discard = req.value.is_none() && !req.legacy;
// `legacy` targets the workspace-level row and is delete-only: an upsert writes the
// caller's own row whatever it says. Every read of that rule goes through this.
let legacy_delete = req.value.is_none() && req.legacy;
// Whose row this write is for: the caller's, or the workspace-level one on a legacy
// DELETE (`legacy` is delete-only, so an upsert is the caller's own row either way).
// It picks both the record that applies — an item's move (`email IS NULL`) covers the
// legacy row too, since the same rename carried it — and the draft whose presence
// means this path is still the write's own.
let owner: Option<&str> = (!legacy_delete).then_some(email.as_str());
// The caller's own draft-only move outranks the move of the deployed item. Only the
// kinds whose value carries a deploy target are ever recorded as moved, so for the
// rest this would be a guaranteed-empty query on the autosave hot path.
let moved_to = match kind.typed_path_field() {
None => None,
Some(_) => {
sqlx::query_scalar!(
r#"SELECT m.new_path FROM draft_move m
WHERE m.workspace_id = $1 AND m.typ = $2 AND m.old_path = $3
AND (m.email IS NULL OR m.email = $4)
AND NOT EXISTS (
SELECT 1 FROM draft d
WHERE d.workspace_id = $1 AND d.typ = $2 AND d.path = $3
AND d.email IS NOT DISTINCT FROM $4
)
ORDER BY m.email IS NULL
LIMIT 1"#,
&w_id,
kind as UserDraftItemKind,
url_path,
owner,
)
.fetch_optional(&db)
.await?
}
};
let path: &str = moved_to.as_deref().unwrap_or(url_path);
// Everything past here writes, so the gate applies from here on. Answered
// without the path when the item moved: it may have gone somewhere the caller
// cannot see.
if !is_own_discard {
require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?;
match require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await {
// Naming the move is for whoever was editing the item: it tells them why a
// save they were already making stopped landing. Someone who cannot read the
// path they addressed gets the plain denial, or the wording itself would
// answer whether an item was moved away from a path they only guessed at.
Err(Error::NotAuthorized(e)) if moved_to.is_some() => {
return Err(Error::NotAuthorized(
match require_can_read_path(&authed, &user_db, &w_id, kind, url_path).await {
Ok(()) => {
"this draft's item was moved to a path you cannot write".to_string()
}
Err(_) => e,
},
));
}
other => other?,
}
}
let applied_at = if let Some(value) = &req.value {
let applied = if let Some(value) = &req.value {
// Secret variable values must never sit in `draft.value` in plaintext
// (see `encrypt_secret_variable_value`).
let serialized = if kind == UserDraftItemKind::Variable {
@@ -359,19 +476,49 @@ async fn update_draft(
// escape and later make any `->>`/`to_jsonb` extraction raise `22P05`.
// Strip it here so a NUL never reaches the column.
let serialized = strip_json_nul(&serialized);
// `base` is derived here from the value's per-kind field rather than sent
// by the client, so every writer (editors, chat, CLI) fills it the same way.
// Read from the sanitized text, which is what the value column gets: a NUL in the
// lineage field otherwise costs the draft its base (the hash no longer parses),
// leaving a draft that reads as up to date whatever the head is.
let base = draft_lineage(kind, serialized.as_ref()).and_then(|l| l.as_text(kind));
// Upsert. The conflict check rides on the DO UPDATE WHERE clause —
// when the row is newer than `last_sync`, RETURNING yields nothing.
// `created_at` defaults to `now()` but the migration overrides it ($8)
// so a migrated draft keeps its original age instead of jumping to top.
sqlx::query_scalar!(
r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at)
VALUES ($1, $2, $3, $4, $5::text::json, COALESCE($8::timestamptz, now()))
//
// A moved save ($10) carries the path keys its editor had before the move.
// One naming the path it addressed ($11) follows to where it landed; a draft
// already there keeps the keys the move gave it. A pre-sanitizer NUL escape
// in that draft makes `to_jsonb` raise, so it takes the incoming keys.
sqlx::query!(
r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)
VALUES ($1, $2, $3::text, $4,
CASE WHEN $10::bool
THEN to_json($5::text::jsonb || jsonb_strip_nulls(jsonb_build_object(
'path', CASE WHEN $5::text::jsonb -> 'path' = to_jsonb($11::text)
THEN to_jsonb($3::text) END,
'draft_path', CASE WHEN $5::text::jsonb -> 'draft_path' = to_jsonb($11::text)
THEN to_jsonb($3::text) END)))
ELSE $5::text::json
END,
COALESCE($8::timestamptz, now()), $9)
ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL
DO UPDATE SET value = EXCLUDED.value, created_at = EXCLUDED.created_at
DO UPDATE SET value = CASE
WHEN NOT $10::bool
OR position(chr(92) || 'u0000' in replace(draft.value::text, chr(92) || chr(92), '')) > 0
THEN EXCLUDED.value
ELSE to_json((to_jsonb(EXCLUDED.value) - 'path' - 'draft_path')
|| jsonb_strip_nulls(jsonb_build_object(
'path', to_jsonb(draft.value) -> 'path',
'draft_path', to_jsonb(draft.value) -> 'draft_path')))
END,
created_at = EXCLUDED.created_at,
base = EXCLUDED.base
WHERE $7::bool = true
OR $6::timestamptz IS NULL
OR draft.created_at <= $6::timestamptz
RETURNING created_at"#,
RETURNING path, created_at"#,
&w_id,
email,
path,
@@ -380,9 +527,13 @@ async fn update_draft(
req.last_sync,
req.force,
req.created_at,
base.as_deref(),
moved_to.is_some(),
url_path,
)
.fetch_optional(&db)
.await?
.map(|r| (r.created_at, Some(r.path)))
} else {
// Delete, same conflict rule in the WHERE clause. Returns NULL when
// the row was too new (conflict) OR already absent (idempotent) —
@@ -403,16 +554,20 @@ async fn update_draft(
kind as UserDraftItemKind,
req.last_sync,
req.force,
req.legacy,
legacy_delete,
)
.fetch_optional(&db)
.await?
// Named for the same reason an upsert is: the editor that discarded is still on
// the path the item left, and reloading there would land on nothing.
.map(|ts| (ts, moved_to.clone()))
};
if let Some(ts) = applied_at {
if let Some((ts, path)) = applied {
return Ok(Json(SaveDraftResponse {
status: SaveDraftStatus::Saved,
current_timestamp: ts,
path,
}));
}
@@ -428,7 +583,7 @@ async fn update_draft(
email,
path,
kind as UserDraftItemKind,
req.legacy,
legacy_delete,
)
.fetch_optional(&db)
.await?;
@@ -437,20 +592,307 @@ async fn update_draft(
Some(ts) => Ok(Json(SaveDraftResponse {
status: SaveDraftStatus::Conflict,
current_timestamp: ts,
path: None,
})),
// Delete + nothing-was-there ⇒ report success with server's NOW().
None => {
let now = sqlx::query_scalar!(r#"SELECT now() as "now!""#)
.fetch_one(&db)
.await?;
// A retry of a routed discard whose answer was lost lands here: the row is
// gone but the editor is still on the path the item left, so it needs the
// destination as much as the first attempt did. Unlike the arm above there is
// no deleted row proving the caller ever held that draft, and an own discard
// is not gated, so this names a path to someone who may have none of it: the
// read gate, which is what keeps a path from being disclosed elsewhere in this
// module. Without it, discarding at a guessed path reads `draft_move`.
let disclosed = match moved_to {
Some(dest) => {
match require_can_read_path(&authed, &user_db, &w_id, kind, &dest).await {
Ok(()) => Some(dest),
Err(Error::NotFound(_))
| Err(Error::NotAuthorized(_))
| Err(Error::BadRequest(_)) => None,
Err(e) => return Err(e),
}
}
None => None,
};
Ok(Json(SaveDraftResponse {
status: SaveDraftStatus::Saved,
current_timestamp: now,
path: disclosed,
}))
}
}
}
#[derive(Deserialize)]
pub struct MoveDraftRequest {
pub new_path: String,
/// Also restate the draft's summary, so the same drawer that renames a
/// deployed item can retitle a draft-only one.
#[serde(default)]
pub summary: Option<String>,
}
/// Relocate the authed user's own DRAFT-ONLY item. Such an item is nothing but
/// its draft row, so moving it is a rewrite of that row's path plus both path
/// keys inside its value — there is no deployed row, schedule or trigger to
/// cascade to.
///
/// The owner's own open editor follows: its next save, still addressed to the old
/// path, lands at the new one through the move record, and it is told where.
///
/// Scoped to the caller's own row on purpose: two users can each have a draft
/// at the same never-deployed path, and those are two separate items.
///
/// A DEPLOYED item must move through its own deploy endpoint instead, which
/// cascades everything that references the path and carries every draft along.
async fn move_draft(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>,
Json(req): Json<MoveDraftRequest>,
) -> Result<String> {
let path = path.to_path();
let new_path = req.new_path.as_str();
// Only the full-page editor kinds, which is exactly the set that has a typed
// path to rewrite. Reading the movable set off the same mapping the rewrite
// uses keeps them from drifting apart: a resource, a variable or a trigger
// keeps its deploy target in `value.path` with no editor to stage a rename,
// so moving one would leave the real target naming the old location and the
// next deploy would recreate it where it came from.
let (Some(typed_field), Some(mirror_field)) =
(kind.typed_path_field(), kind.mirror_path_field())
else {
return Err(Error::BadRequest(format!(
"moving a {kind:?} draft is not supported — only scripts, flows and apps"
)));
};
// Validate before authorizing: `require_can_write_path` is not a format check
// (an admin returns immediately, and a user returns early inside their own
// namespace), so without this a malformed path is stored as-is, and an over-long
// or NUL-bearing one reaches Postgres as a raw server error. The source is a URL
// segment and arrives decoded, so it needs the same check as the destination.
check_proper_path(path)?;
check_proper_path(new_path)?;
// A summary-only edit is a legitimate use of this endpoint: the drawer edits
// both fields, and for a draft-only script the path it posts back is the row
// path unchanged (`list_scripts` only reports `draft_path` when it differs).
// Returning early on the path alone would drop the new summary silently.
if new_path == path && req.summary.is_none() {
return Ok("unchanged".to_string());
}
require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?;
if new_path != path {
require_can_write_path(&authed, &db, &user_db, &w_id, kind, new_path).await?;
}
if let Some(table) = kind.deployed_table() {
// `table` is from the closed `deployed_table()` enum, never user input.
// Archived and soft-deleted rows keep sitting at their path — a script
// move archives its parent in place — so an existence check that counted
// them would refuse a move away from, or into, a path nothing occupies.
// `create_script_internal` resolves its own path clashes the same way.
let archived_filter = if table == "script" {
" AND NOT archived AND NOT deleted"
} else {
""
};
let query = format!(
"SELECT 1 FROM {table} WHERE path = $1 AND workspace_id = $2{archived_filter} LIMIT 1"
);
let mut tx = user_db.clone().begin(&authed).await?;
let deployed_at_old = sqlx::query_scalar::<_, i32>(&query)
.bind(path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
let deployed_at_new = sqlx::query_scalar::<_, i32>(&query)
.bind(new_path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
if deployed_at_old.is_some() {
return Err(Error::BadRequest(format!(
"'{path}' is deployed — move it from its editor so schedules and triggers follow"
)));
}
if deployed_at_new.is_some() {
return Err(Error::BadRequest(format!(
"'{new_path}' already has a deployed item — moving there would turn this into a draft on top of it"
)));
}
}
// A NUL in the summary reaches Postgres as a raw byte and fails the statement with
// an encoding error, so it is dropped here the way `strip_json_nul` drops one from a
// draft value. `json` cannot store it either.
let summary = req.summary.as_ref().map(|s| s.replace('\0', ""));
// A classic app and a raw app share the `app` table, so a draft of either kind
// occupies the destination for both: deploying there deletes the caller's drafts
// of both kinds, taking the item that lost the collision with it.
let collision_typs: Vec<&str> = match kind {
UserDraftItemKind::App | UserDraftItemKind::RawApp => vec![
UserDraftItemKind::App.as_str(),
UserDraftItemKind::RawApp.as_str(),
],
_ => vec![kind.as_str()],
};
// One transaction with the move record, so a save addressed to the old path
// never sees the row gone without knowing where it went.
let mut tx = db.begin().await?;
let moved = sqlx::query_scalar!(
r#"UPDATE draft
SET path = $3,
-- Both path keys, not just the typed one: the editors mirror the
-- typed path into the other while it differs from the row's path,
-- and the loaders prefer the mirror — left naming the old location
-- it un-does this move on the next save. `create_missing = false`
-- on both, so a draft carrying only one keeps only one.
value = to_json(
jsonb_set(
jsonb_set(
CASE WHEN $7::text IS NULL THEN to_jsonb(value)
ELSE jsonb_set(to_jsonb(value), ARRAY['summary'], to_jsonb($7::text))
END,
ARRAY[$5::text], to_jsonb($3::text), false
),
ARRAY[$8::text], to_jsonb($3::text), false
)
)
WHERE workspace_id = $1
AND path = $2
AND typ = $4
AND email = $6
-- A pre-sanitizer NUL escape makes `to_jsonb` raise 22P05. Excluded
-- here so the statement can't 500; reported below instead. Unlike the
-- passive carry, rewriting the value IS this operation, so skipping it
-- silently would move the row and leave its typed path stale.
AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) = 0
-- Skipped on a summary-only edit, where the "target" row is this
-- row and the guard would refuse the update against itself.
AND ($2 = $3 OR NOT EXISTS (
SELECT 1 FROM draft o
WHERE o.workspace_id = $1 AND o.path = $3 AND o.typ::text = ANY($9::text[])
-- Of this kind only the caller's own row and the legacy one collide:
-- teammates' drafts of one item share its path by design, but a deploy
-- there wipes those two together, so a second would discard edits the
-- caller never saw. The other app kind is a different item on the same
-- deployed path, so it collides whoever owns it.
AND (o.typ <> $4 OR o.email = $6 OR o.email IS NULL)
))
RETURNING id"#,
&w_id,
path,
new_path,
kind as UserDraftItemKind,
typed_field,
&authed.email,
summary,
mirror_field,
&collision_typs as &[&str],
)
.fetch_optional(&mut *tx)
.await?;
if moved.is_some() && new_path != path {
windmill_common::user_drafts::record_draft_move(
&mut tx,
&w_id,
&[kind],
path,
new_path,
Some(&authed.email),
)
.await?;
}
tx.commit().await?;
if moved.is_none() {
let row = sqlx::query!(
r#"SELECT
-- The guard's own predicate, ordered own row, then legacy, then another
-- user's other-kind row: each needs a different remedy, and a bare
-- LIMIT 1 would name an arbitrary one.
(SELECT typ::text FROM draft WHERE workspace_id = $1 AND path = $3
AND typ::text = ANY($6::text[])
AND (typ <> $2 OR email = $4 OR email IS NULL)
ORDER BY CASE WHEN email = $4 THEN 0 WHEN email IS NULL THEN 1 ELSE 2 END
LIMIT 1) as "at_target",
EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3
AND typ::text = ANY($6::text[]) AND email = $4) as "at_target_own!",
EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3
AND typ::text = ANY($6::text[]) AND email IS NULL) as "at_target_legacy!",
EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5
AND typ = $2 AND email = $4
AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0
) as "poisoned!",
EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5
AND typ = $2 AND email IS NULL) as "legacy!" "#,
&w_id,
kind as UserDraftItemKind,
new_path,
&authed.email,
path,
&collision_typs as &[&str],
)
.fetch_one(&db)
.await?;
return Err(Error::BadRequest(if row.poisoned {
// This endpoint also serves a summary-only edit, so name the operation
// the caller actually asked for rather than always saying "moved".
let attempted = if new_path == path { "updated" } else { "moved" };
format!(
"'{path}' contains a NUL character and predates the sanitizer, so it cannot be \
{attempted}. Reopen it, re-save to rewrite it cleanly, then retry."
)
} else if row.legacy {
// The home list synthesizes a draft-only row for the legacy draft with the
// caller's own name on it, so this is reachable from the row menu. Only an
// admin can claim or discard that row, and only from the drafts page.
format!(
"'{path}' is a legacy workspace draft with no owner, so it cannot be moved. \
A workspace admin can claim or discard it on the Review & deploy drafts page."
)
} else if let Some(occupant) = row.at_target.filter(|_| new_path != path) {
// Naming the kind matters for the app pair: a classic-app draft refusing a
// raw-app move is invisible in the raw-app list the caller is looking at.
let occupant = occupant.replace('_', " ");
if row.at_target_own {
format!("You already have a draft at '{new_path}' ({occupant})")
} else if row.at_target_legacy {
// An ownerless row the caller cannot clear themselves, so send them to
// the one place it can be resolved rather than to "discard your draft".
format!(
"A legacy workspace draft with no owner is already at '{new_path}' \
({occupant}). A workspace admin can claim or discard it on the Review & \
deploy drafts page."
)
} else {
// The other app kind, owned by someone else: one deployed path cannot hold
// both, so this is the other item's path, not a teammate's copy of this one.
format!(
"'{new_path}' holds another user's {occupant} draft, and an app and a raw \
app cannot share a path. Pick another path, or ask them to move or \
discard theirs."
)
}
} else {
format!("You have no draft at '{path}'")
}));
}
if new_path == path {
return Ok(format!("updated draft {path}"));
}
Ok(format!("moved draft {path} to {new_path}"))
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum MigrateLegacyDraftAction {
@@ -504,12 +946,12 @@ async fn migrate_legacy_draft(
r#"WITH legacy AS (
DELETE FROM draft
WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL
RETURNING value
RETURNING value, base
)
INSERT INTO draft (workspace_id, email, path, typ, value, created_at)
SELECT $1, $4, $2, $3, value, now() FROM legacy
INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)
SELECT $1, $4, $2, $3, value, now(), base FROM legacy
ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL
DO UPDATE SET value = EXCLUDED.value, created_at = now()
DO UPDATE SET value = EXCLUDED.value, created_at = now(), base = EXCLUDED.base
RETURNING 1 as "one!""#,
&w_id,
path,
@@ -767,8 +1209,8 @@ async fn require_can_write_path(
/// Resolves to `Ok(())` if `authed` can read at `path`. Three layers:
/// 1. admin → always.
/// 2. Path-prefix match against own `u/{username}` or any folder in
/// `authed.folders` (the precomputed read set, with groups + direct
/// 2. Path-prefix match against own `u/{username}`, a group in `authed.groups`, or any
/// folder in `authed.folders` (the precomputed read set, with groups + direct
/// grants already factored in).
/// 3. RLS-aware `SELECT 1` against the backing table — covers item-level
/// extra_perms grants that bypass folder/owner checks.
@@ -793,6 +1235,10 @@ async fn require_can_read_path(
if parts.len() >= 2 {
match parts[0] {
"u" if parts[1] == authed.username => return Ok(()),
// As `require_can_write_path` and the `see_member` RLS policy read it: a
// draft-only `g/` path has no row for the probe below to fall back on, so
// without this a member cannot see a draft their group owns.
"g" if authed.groups.iter().any(|g| g == parts[1]) => return Ok(()),
"f" => {
let folder = parts[1];
if authed.folders.iter().any(|(name, _, _)| name == folder) {
+313 -8
View File
@@ -160,6 +160,34 @@ impl UserDraftItemKind {
}
}
/// 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
@@ -212,6 +240,12 @@ pub struct WithDraftOverlay {
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
@@ -297,6 +331,7 @@ where
inner: Box::new(deployed),
is_draft: false,
draft_saved_at: None,
draft_base: None,
no_deployed: false,
draft: None,
other_drafts_users: Vec::new(),
@@ -317,7 +352,7 @@ where
// 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
created_at, base
FROM draft
WHERE workspace_id = $1
AND (email = $2 OR email IS NULL)
@@ -338,6 +373,7 @@ where
inner: Box::new(deployed),
is_draft: false,
draft_saved_at: None,
draft_base: None,
no_deployed: false,
draft: None,
other_drafts_users,
@@ -350,6 +386,7 @@ where
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,
@@ -468,7 +505,8 @@ pub async fn overlay_or_draft_only<T: serde::Serialize + Send + 'static>(
}
}
/// Delete the drafts an address owns, across every workspace.
/// 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
@@ -479,12 +517,14 @@ pub async fn overlay_or_draft_only<T: serde::Serialize + Send + 'static>(
/// 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<'c>(
executor: impl sqlx::PgExecutor<'c>,
email: &str,
) -> Result<()> {
pub async fn delete_drafts_of_email(conn: &mut sqlx::PgConnection, email: &str) -> Result<()> {
sqlx::query!("DELETE FROM draft WHERE email = $1", email)
.execute(executor)
.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(())
}
@@ -526,6 +566,29 @@ pub async fn rename_drafts_of_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(())
}
@@ -582,6 +645,247 @@ pub async fn delete_own_draft_for_path(
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
@@ -600,7 +904,7 @@ pub async fn fetch_draft_only(
// 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
created_at, base
FROM draft
WHERE workspace_id = $1
AND (email = $2 OR email IS NULL)
@@ -631,6 +935,7 @@ pub async fn fetch_draft_only(
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,
+47
View File
@@ -479,6 +479,28 @@ pub fn paginate(pagination: Pagination) -> (usize, usize) {
(per_page, offset)
}
/// [`paginate`] for a listing that answers whole unless a size is asked for: the deploy
/// histories, which the history panels and the CLI read unpaged while the diff picker takes
/// a page at a time. An asked-for size is still clamped, and the offset saturates rather
/// than wrapping, so no caller can turn this into an unbounded scan or a negative bind.
pub fn paginate_optional(pagination: Pagination) -> (i64, i64) {
// Naming neither parameter asks for the whole listing, the contract these endpoints
// have always answered on. Naming either makes it a page like any other listing's.
if pagination.page.is_none() && pagination.per_page.is_none() {
return (i64::MAX, 0);
}
let per_page = pagination
.per_page
.unwrap_or(DEFAULT_PER_PAGE)
.clamp(1, MAX_PER_PAGE) as i64;
// Bound before Postgres sees it: an unchecked cast of a caller-controlled page becomes
// a negative OFFSET, which is an error rather than an empty page.
let offset = i64::try_from(pagination.page.unwrap_or(1).max(1) - 1)
.unwrap_or(i64::MAX)
.saturating_mul(per_page);
(per_page, offset)
}
pub fn paginate_without_limits(pagination: Pagination) -> (usize, usize) {
let per_page = pagination.per_page.unwrap_or(MAX_PER_PAGE);
let offset = (pagination.page.unwrap_or(1).max(1) - 1) * per_page;
@@ -1672,6 +1694,31 @@ pub fn truncate_with_ellipsis(s: &str, max_chars: usize) -> String {
mod tests {
use super::*;
#[test]
fn test_paginate_optional_answers_whole_but_bounds_what_is_asked_for() {
// Nothing asked for: every row there can be, which is what the history panels and
// the CLI read.
assert_eq!(
paginate_optional(Pagination { page: None, per_page: None }),
(i64::MAX, 0)
);
assert_eq!(
paginate_optional(Pagination { page: Some(3), per_page: Some(20) }),
(20, 40)
);
// An asked-for size is still capped, so no caller turns this into an unbounded scan.
assert_eq!(
paginate_optional(Pagination { page: None, per_page: Some(usize::MAX) }),
(MAX_PER_PAGE as i64, 0)
);
// A page nobody could mean lands past the end rather than going negative, which
// Postgres would reject outright.
let (per_page, offset) =
paginate_optional(Pagination { page: Some(usize::MAX), per_page: Some(20) });
assert_eq!(per_page, 20);
assert_eq!(offset, i64::MAX);
}
/// A 5-field crontab line is the most common way to get a schedule rejected, and both
/// parsers report it in terms a crontab user cannot act on, so the seconds field and the
/// equivalent expression must reach the caller for v1 and v2 alike.
@@ -6457,7 +6457,7 @@ var AppService = class {
* @param data.workspace
* @param data.path
* @param data.requestBody update app
* @returns string app updated
* @returns AppDeployed the path the app now lives at and the version this deploy wrote
* @throws ApiError
*/
static updateApp(data) {
+4
View File
@@ -507,6 +507,10 @@ pub struct ScriptHistory {
pub deployment_msg: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
/// Who deployed this version — the version picker names it, so a reader can
/// tell their own deploys from a teammate's.
#[serde(skip_serializing_if = "Option::is_none")]
pub created_by: Option<String>,
}
#[derive(Deserialize)]
+21 -10
View File
@@ -3594,12 +3594,15 @@ export async function pull(
) {
if ((opts as any).jsonOutput) log.setSilent(true);
const originalCliOpts = { ...opts };
opts = await mergeConfigWithConfigFile(opts);
// --include-secrets overrides skipSecrets from wmill.yaml
if ((originalCliOpts as any).includeSecrets) {
opts.skipSecrets = false;
}
const withConfigFile = async () => {
const merged = await mergeConfigWithConfigFile({ ...originalCliOpts });
// --include-secrets overrides skipSecrets from wmill.yaml
if ((originalCliOpts as any).includeSecrets) {
merged.skipSecrets = false;
}
return merged;
};
opts = await withConfigFile();
// Resolve workspace name for config lookups.
// --branch resolves git branch → workspace name (deprecated but still supported).
@@ -3634,10 +3637,6 @@ export async function pull(
throw error;
}
if (opts.stateful) {
await mkdir(path.join(process.cwd(), ".wmill"), { recursive: true });
}
const workspace = await resolveWorkspace(opts, wsNameForConfig);
await requireLogin(opts);
@@ -3729,6 +3728,14 @@ export async function pull(
});
return;
}
// The pull writes into the branch now checked out, so its wmill.yaml
// applies, not the cloned branch's: a fork branch that turned on
// `dedupeLockfiles` would otherwise get one lockfile per script back.
if (getCurrentGitBranch() !== clonedBranchName) {
opts = await withConfigFile();
wsNameForConfig = resolveWsNameForConfigFromFlags(opts);
}
}
// If wsNameForConfig wasn't set from flags, infer from the resolved profile
@@ -3763,6 +3770,10 @@ export async function pull(
// Merge CLI flags with resolved settings (CLI flags take precedence only for explicit overrides)
opts = mergeCliWithEffectiveOptions(originalCliOpts, effectiveOpts);
if (opts.stateful) {
await mkdir(path.join(process.cwd(), ".wmill"), { recursive: true });
}
const codebases = await listSyncCodebases(opts);
log.info(
@@ -0,0 +1,91 @@
import { expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createRemoteWorkspaceDeps, withTestBackend } from "./test_backend.ts";
function git(cwd: string, ...args: string[]): string {
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
}
// The git-sync deploy callback starts in a clone of the tracked branch and
// switches to the fork's branch before pulling. What it writes there must follow
// that branch's wmill.yaml: a fork branch that turned on `dedupeLockfiles` keeps
// its shared lockfile instead of getting a `.script.lock` per script back.
test("git-sync fork deploy follows the fork branch's wmill.yaml", async () => {
await withTestBackend(async (backend) => {
const post = (path: string, body: unknown) =>
backend.apiRequest!(`/api/w/${backend.workspace}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
await createRemoteWorkspaceDeps(backend, "python3", "wmill\n");
await post("/folders/create", { name: "dedupe" });
const script = await post("/scripts/create", {
path: "f/dedupe/a",
summary: "",
description: "",
content: "def main():\n pass\n",
language: "python3",
lock: "wmill==1.0.0\n",
});
expect(script.ok).toBe(true);
const bare = await mkdtemp(join(tmpdir(), "wmill_deploy_cfg_bare_"));
const seed = await mkdtemp(join(tmpdir(), "wmill_deploy_cfg_seed_"));
const work = await mkdtemp(join(tmpdir(), "wmill_deploy_cfg_work_"));
try {
execFileSync("git", ["init", "--bare", "--initial-branch=main", bare]);
git(seed, "init", "--initial-branch=main");
git(seed, "config", "user.email", "seed@windmill.dev");
git(seed, "config", "user.name", "seed");
git(seed, "remote", "add", "origin", `file://${bare}`);
const wmillYaml = "defaultTs: bun\nincludes:\n - f/dedupe/**\nexcludes: []\n";
await writeFile(join(seed, "wmill.yaml"), wmillYaml);
git(seed, "add", "-A");
git(seed, "commit", "-m", "main");
git(seed, "push", "origin", "main");
// Any parent id makes the callback deploy as a fork, to this branch.
const forkBranch = `wm-fork/main/${backend.workspace}`;
git(seed, "checkout", "-b", forkBranch);
await writeFile(
join(seed, "wmill.yaml"),
wmillYaml + "dedupeLockfiles: true\n",
);
const pulled = await backend.runCLICommand(["sync", "pull", "--yes"], seed);
expect(pulled.code).toBe(0);
expect(existsSync(join(seed, "locks/requirements.in.lock"))).toBe(true);
git(seed, "add", "-A");
git(seed, "commit", "-m", "dedupe");
git(seed, "push", "origin", forkBranch);
git(work, "clone", `file://${bare}`, ".");
const deployed = await backend.runCLICommand(
[
"sync",
"git-deploy",
"--repository",
"u/test/unused",
"--git-deploy-items",
JSON.stringify([
{ path_type: "script", path: "f/dedupe/a", commit_msg: "deploy" },
]),
"--parent-workspace-id",
"parent",
],
work,
);
expect(deployed.code).toBe(0);
expect(git(work, "rev-parse", "--abbrev-ref", "HEAD")).toBe(forkBranch);
expect(git(work, "status", "--porcelain")).toBe("");
} finally {
await rm(bare, { recursive: true, force: true });
await rm(seed, { recursive: true, force: true });
await rm(work, { recursive: true, force: true });
}
});
});
+41 -246
View File
@@ -67,7 +67,7 @@
"panzoom": "^9.4.3",
"pdfjs-dist": "4.8.69",
"quicktype-core": "^23.2.6",
"quill": "^1.3.7",
"quill": "^2.0.3",
"rehype-github-alerts": "^3.0.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
@@ -3746,28 +3746,11 @@
"node": ">=8.10.0"
}
},
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.0",
"es-define-property": "^1.0.0",
"get-intrinsic": "^1.2.4",
"set-function-length": "^1.2.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -3781,6 +3764,7 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -5222,26 +5206,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/deep-equal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz",
"integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
"license": "MIT",
"dependencies": {
"is-arguments": "^1.1.1",
"is-date-object": "^1.0.5",
"is-regex": "^1.1.4",
"object-is": "^1.1.5",
"object-keys": "^1.1.1",
"regexp.prototype.flags": "^1.5.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
@@ -5284,40 +5248,6 @@
"node": ">=6"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/define-properties": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.0.1",
"has-property-descriptors": "^1.0.0",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/defu": {
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
@@ -5570,6 +5500,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -5684,6 +5615,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5693,6 +5625,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5709,6 +5642,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -6176,9 +6110,9 @@
}
},
"node_modules/eventemitter3": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/events": {
@@ -6233,9 +6167,9 @@
"license": "MIT"
},
"node_modules/fast-diff": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
"integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"license": "Apache-2.0"
},
"node_modules/fast-equals": {
@@ -6522,15 +6456,7 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/functions-have-names": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -6569,6 +6495,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -6593,6 +6520,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -6776,6 +6704,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -6868,22 +6797,11 @@
"node": ">=8"
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -6896,6 +6814,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -6917,6 +6836,7 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -7315,22 +7235,6 @@
"node": ">=12"
}
},
"node_modules/is-arguments": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@@ -7368,22 +7272,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-date-object": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
"integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -7474,24 +7362,6 @@
"@types/estree": "^1.0.6"
}
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"gopd": "^1.2.0",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
@@ -8686,6 +8556,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -10139,31 +10010,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-is": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -10356,9 +10202,9 @@
}
},
"node_modules/parchment": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
"license": "BSD-3-Clause"
},
"node_modules/parent-module": {
@@ -11677,31 +11523,32 @@
}
},
"node_modules/quill": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
"integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
"license": "BSD-3-Clause",
"dependencies": {
"clone": "^2.1.1",
"deep-equal": "^1.0.1",
"eventemitter3": "^2.0.3",
"extend": "^3.0.2",
"parchment": "^1.1.4",
"quill-delta": "^3.6.2"
"eventemitter3": "^5.0.1",
"lodash-es": "^4.17.21",
"parchment": "^3.0.0",
"quill-delta": "^5.1.0"
},
"engines": {
"npm": ">=8.2.3"
}
},
"node_modules/quill-delta": {
"version": "3.6.3",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
"integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
"license": "MIT",
"dependencies": {
"deep-equal": "^1.0.1",
"extend": "^3.0.2",
"fast-diff": "1.1.2"
"fast-diff": "^1.3.0",
"lodash.clonedeep": "^4.5.0",
"lodash.isequal": "^4.5.0"
},
"engines": {
"node": ">=0.10"
"node": ">= 12.0.0"
}
},
"node_modules/rbush": {
@@ -11874,26 +11721,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
"integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"define-properties": "^1.2.1",
"es-errors": "^1.3.0",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"set-function-name": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/rehype-github-alerts": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/rehype-github-alerts/-/rehype-github-alerts-3.0.0.tgz",
@@ -12290,38 +12117,6 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/set-function-name": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
"integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"functions-have-names": "^1.2.3",
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+1 -1
View File
@@ -145,7 +145,7 @@
"panzoom": "^9.4.3",
"pdfjs-dist": "4.8.69",
"quicktype-core": "^23.2.6",
"quill": "^1.3.7",
"quill": "^2.0.3",
"rehype-github-alerts": "^3.0.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
+273 -16
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { Alert, Button, Drawer, DrawerContent } from './common'
import { Loader2 } from 'lucide-svelte'
import { ArrowRight, Loader2 } from 'lucide-svelte'
import { scriptLangToEditorLang } from '$lib/scripts'
import Tabs from './common/tabs/Tabs.svelte'
import Tab from './common/tabs/Tab.svelte'
@@ -11,7 +11,10 @@
type Value
} from '$lib/utils'
import { orderedYamlStringify } from '$lib/utils/orderedYaml'
import { sendUserToast } from '$lib/toast'
import type { Script } from '$lib/gen'
import Select from './select/Select.svelte'
import type { DiffVersionOption } from './diff_drawer'
type DiffData = {
lang?: string
@@ -44,6 +47,13 @@
| {
mode: 'normal'
deployed: DiffData | undefined
/** Which deployed version the left side is — the reader otherwise has no
* way to tell what their draft is being compared against. */
deployedLabel?: string
versions?: DiffVersionOption[]
onTakeLatest?: (head?: string) => void | Promise<void>
draftBase?: string
deployedHead?: string
draft: DiffData | undefined
current: DiffData
path?: string
@@ -58,7 +68,13 @@
}
| undefined = $state(undefined)
export function openDrawer() {
export function openDrawer(token?: number) {
// No token means the caller is taking the drawer for itself, so claim one here:
// every reuse (a deploy-override's "Show diff", a draft badge, a workspace
// comparison) then invalidates an editor opening that is still fetching, instead
// of being replaced by it when it lands.
if (token != null && token !== openingToken) return
if (token == null) openingToken++
data = undefined
diffType = undefined
diffViewer?.openDrawer()
@@ -68,6 +84,36 @@
diffViewer?.closeDrawer()
}
/** Counted per opening, and counted here rather than in the editor that opens one: a
* path change remounts the editor while this drawer stays, so a counter local to it is
* one an outlived request still matches, and that request would fill the drawer with
* the item the user just left. Every write an opening makes checks this first. */
let openingToken = 0
export function beginOpening(): number {
return ++openingToken
}
export function ownsOpening(token: number): boolean {
return token === openingToken
}
/** Drop an opening and everything it put on screen: its editor is going away, so a diff
* it filled acts on an item that is gone (Take latest would move the base of whatever
* loaded in its place) and one still in flight would leave the spinner behind. A no-op
* once another opening owns the drawer: what it shows is then someone else's. */
export function abandonOpening(token: number) {
if (token !== openingToken) return
openingToken++
// The version load and the page in flight, if any, belong to the diff being dropped.
versionLoadGeneration++
versionListGeneration++
loadingVersion = false
data = undefined
diffType = undefined
diffViewer?.closeDrawer()
}
function prepareDiff(data: Value) {
const metadata = structuredClone(cleanValueProperties(replaceFalseWithUndefined(data)))
const content = metadata['content']
@@ -81,11 +127,145 @@
}
}
// Which deployed version the left pane shows. The drawer keeps the id opaque and
// hands it back to the editor, which knows how to fetch it for its own kind.
let selectedVersion: string | undefined = $state(undefined)
let versionLoader: ((id: string) => Promise<Value | undefined>) | undefined = $state(undefined)
let headLabel: string | undefined = $state(undefined)
/** The deployed head, prepared for diffing as `data.deployed` is: `data.deployed`
* follows the picker while Restore always restores the head, so its enabled state
* compares against this. Must hold `prepareDiff`'s output, not the raw value, or the
* comparison never matches and Restore is always enabled. */
let headDeployed: ReturnType<typeof prepareDiff> | undefined = $state(undefined)
let loadingVersion = $state(false)
/** Which version load the spinner belongs to, counted rather than keyed on the id so
* re-picking the same version is still generation-safe. A response from any other
* generation is stale — a slower earlier pick, or one outlived by a drawer reset —
* and neither replaces the diff nor clears the spinner, which `disabled` rides on. */
let versionLoadGeneration = 0
/** Counted separately from `versionLoadGeneration`: only a diff replacing this one
* invalidates a page in flight. Picking a version while one loads is not a reason to
* drop it — the editor's cursor has already moved past that page, so discarding it
* would skip it until the drawer is reopened. */
let versionListGeneration = 0
/** Pages of `versions` fetched after the first. Kept beside `data` so a diff swapped in
* by a newer opening drops them along with the list they extended. */
let extraVersions: DiffVersionOption[] = $state([])
let moreLoader: (() => Promise<DiffVersionOption[] | undefined>) | undefined = $state(undefined)
let loadingMore = $state(false)
/** What the picker offers: the page the editor handed over plus whatever the reader has
* asked for since. Every read of the version list goes through this. */
const pickerVersions = $derived.by(() =>
data?.mode === 'normal' ? [...(data.versions ?? []), ...extraVersions] : []
)
/** The editors hand over one page, so a path a pipeline has deployed thousands of times
* does not hold the drawer shut while its whole history arrives. */
async function fetchMoreVersions() {
if (!moreLoader || loadingMore) return
loadingMore = true
const generation = versionListGeneration
try {
const more = await moreLoader()
// A diff swapped in while this ran owns the picker now; appending would splice
// one item's history onto another's.
if (generation !== versionListGeneration) return
if (more?.length) {
extraVersions = [...extraVersions, ...more]
} else {
// Nothing came back, so there is nothing further to ask for.
moreLoader = undefined
}
} catch (e: any) {
if (generation === versionListGeneration) {
sendUserToast(`Could not load older versions: ${e?.body ?? e?.message ?? e}`, true)
}
} finally {
// Guarded like the writes above: an outlived request clearing this would hand
// the drawer that replaced it a second concurrent page.
if (generation === versionListGeneration) loadingMore = false
}
}
async function selectVersion(id: string | undefined) {
if (!id || !versionLoader || !data || data.mode !== 'normal') return
const shown = selectedVersion
selectedVersion = id
const generation = ++versionLoadGeneration
loadingVersion = true
try {
const value = await versionLoader(id)
if (generation !== versionLoadGeneration) return
if (!value || !data || data.mode !== 'normal') {
// Nothing loaded: the diff still shows the previous version, so the picker
// has to say so rather than name one the reader is not looking at.
selectedVersion = shown
if (!value) sendUserToast(`Could not load version ${id}`, true)
return
}
const opt = pickerVersions.find((v) => v.id === id)
data = {
...data,
deployed: prepareDiff(value),
deployedLabel: opt?.isHead ? headLabel : opt?.label
}
} catch (e: any) {
// The snap-back would otherwise be the only sign the version never loaded.
if (generation === versionLoadGeneration) {
selectedVersion = shown
sendUserToast(`Could not load version ${id}: ${e?.body ?? e?.message ?? e}`, true)
}
} finally {
if (generation === versionLoadGeneration) loadingVersion = false
}
}
/** The version this drawer presents as the deployed head: the one its list marks, else
* the head the editor knows. Both the action's gate and the base it adopts hang off
* it, so "take latest" means the version the reader is looking at. */
const headShown = $derived.by(() =>
data?.mode === 'normal'
? (pickerVersions.find((v) => v.isHead)?.id ?? data.deployedHead)
: undefined
)
/** Behind as the drawer can see it. Unknown counts as not behind: offering to adopt a
* head nobody could name would move the base to a version never shown. */
const behindShown = $derived.by(
() =>
data?.mode === 'normal' &&
data.draftBase != null &&
headShown != null &&
data.draftBase !== headShown
)
let takingLatest = $state(false)
async function takeLatest() {
if (!data || data.mode !== 'normal' || !data.onTakeLatest || takingLatest) return
takingLatest = true
// Persisting the base is awaited, and this drawer outlives the editor that filled
// it: close only the opening this action belongs to, or it takes down whichever
// diff was opened meanwhile.
const opening = openingToken
try {
await data.onTakeLatest(headShown)
if (opening === openingToken) diffViewer?.closeDrawer()
} finally {
takingLatest = false
}
}
export function setDiff(
diff:
| {
mode: 'normal'
deployed: Value
deployedLabel?: string
versions?: DiffVersionOption[]
loadVersion?: (id: string) => Promise<Value | undefined>
loadMoreVersions?: () => Promise<DiffVersionOption[] | undefined>
onTakeLatest?: (head?: string) => void | Promise<void>
draftBase?: string
deployedHead?: string
draft?: Value | undefined
current: Value
defaultDiffType?: 'deployed' | 'draft'
@@ -97,13 +277,46 @@
current: Value
title: string
button?: { text: string; onClick: () => void }
}
},
token?: number
) {
// Same rule as `openDrawer`: an opening's own token has to still be current, and a
// caller with none is taking the drawer, so it claims one.
if (token != null && token !== openingToken) return
if (token == null) openingToken++
if (diff.mode === 'normal') {
const { deployed, draft, current, button } = diff
const {
deployed,
deployedLabel,
versions,
loadVersion,
loadMoreVersions,
onTakeLatest,
draftBase,
deployedHead,
draft,
current,
button
} = diff
versionLoader = loadVersion
moreLoader = loadMoreVersions
headLabel = deployedLabel
headDeployed = !deployed.draft_only ? prepareDiff(deployed) : undefined
// A load or page still in flight belongs to the diff being replaced.
versionLoadGeneration++
versionListGeneration++
loadingVersion = false
extraVersions = []
loadingMore = false
selectedVersion = versions?.find((v) => v.isHead)?.id
data = {
mode: 'normal',
deployed: !deployed.draft_only ? prepareDiff(deployed) : undefined,
deployedLabel,
versions,
onTakeLatest,
draftBase,
deployedHead,
draft: draft ? prepareDiff(draft) : undefined,
current: prepareDiff(current),
path: draft?.path || deployed?.path,
@@ -130,19 +343,46 @@
<Drawer bind:this={diffViewer} size="1200px" on:close>
<DrawerContent title="Diff" on:close={diffViewer.closeDrawer}>
<div class="flex flex-col gap-4 h-full">
{#if data?.mode === 'normal'}
<Button
unifiedSize="md"
variant="default"
wrapperClasses="self-start"
onClick={restoreDeployed}
disabled={!data.draft &&
orderedJsonStringify(data.deployed) === orderedJsonStringify(data.current)}
>
Restore to deployed{data.draft ? ' and discard draft' : ''}
</Button>
{/if}
{#if data}
<!-- Outside the `contentType` check on purpose: with no differences against
the head, picking an older version is exactly how the reader finds one,
so the picker has to outlive the "no changes" state. -->
{#if data.mode === 'normal' && (data.deployedLabel || pickerVersions.length)}
<div class="flex gap-2 items-center text-xs text-secondary">
{#if pickerVersions.length && versionLoader}
<div class="w-72">
<Select
items={pickerVersions.map((v) => ({
value: v.id,
label: v.label,
subtitle: v.subtitle
}))}
bind:value={() => selectedVersion, (v) => selectVersion(v as string)}
disabled={loadingVersion}
clearable={false}
/>
</div>
{#if loadingVersion}
<Loader2 size={12} class="animate-spin shrink-0" />
{/if}
{#if moreLoader}
<Button
variant="subtle"
unifiedSize="2xs"
disabled={loadingMore}
on:click={fetchMoreVersions}>Load older</Button
>
{/if}
{#if loadingMore}
<Loader2 size={12} class="animate-spin shrink-0" />
{/if}
{:else if data.deployedLabel}
<span class="font-medium text-primary">{data.deployedLabel}</span>
{/if}
<ArrowRight size={12} class="shrink-0" />
<span>your draft</span>
</div>
{/if}
{#if contentType}
{@const content =
data.mode === 'normal' ? data.deployed?.content : data.original?.content}
@@ -224,6 +464,23 @@
{/if}
</div>
{#snippet actions()}
{#if data?.mode === 'normal' && data.onTakeLatest && behindShown}
<Button unifiedSize="sm" variant="default" loading={takingLatest} onClick={takeLatest}>
Take latest, keep my edits
</Button>
{/if}
{#if data?.mode === 'normal'}
<Button
unifiedSize="sm"
variant="default"
onClick={restoreDeployed}
disabled={!data.draft &&
orderedJsonStringify(headDeployed ?? data.deployed) ===
orderedJsonStringify(data.current)}
>
Restore to deployed{data.draft ? ' and discard draft' : ''}
</Button>
{/if}
{#if data?.button}
<Button
variant="subtle"
@@ -0,0 +1,25 @@
<script lang="ts">
import { Alert, Button } from '$lib/components/common'
interface Props {
/** Take the draft the server holds, replacing what is on screen. */
onReload: () => void
/** Write what is on screen over the server's draft. */
onOverwrite: () => void
}
let { onReload, onOverwrite }: Props = $props()
</script>
<Alert type="warning" title="Your draft changed elsewhere">
<div class="flex flex-col items-start gap-2">
<div>
It was saved from another tab or session since this one read it, so your changes here are no
longer being saved.
</div>
<div class="flex flex-row gap-2">
<Button unifiedSize="sm" variant="default" onClick={onReload}>Load the other version</Button>
<Button unifiedSize="sm" variant="default" onClick={onOverwrite}>Keep mine</Button>
</div>
</div>
</Alert>
+135 -17
View File
@@ -10,6 +10,7 @@
type Job
} from '$lib/gen'
import { initHistory, redo, undo } from '$lib/history.svelte'
import { VERSION_PAGE_SIZE } from '$lib/components/diff_drawer'
import {
clearLinkedAgentTools,
linkedAgentToolsForScope,
@@ -41,7 +42,7 @@
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte'
import { getContext, setContext, untrack } from 'svelte'
import { getContext, onDestroy, setContext, untrack } from 'svelte'
import { writable } from 'svelte/store'
import CenteredPage from './CenteredPage.svelte'
import { Button } from './common'
@@ -114,6 +115,11 @@
let {
initialPath = $bindable(''),
/** The draft row's own path (the URL path). Unlike `initialPath`, which the
* route re-seeds from `draft_path` so the topbar shows the pending name,
* this stays where the item actually is. */
userDraftPath = '',
onTakeLatest = undefined,
pathStoreInit = undefined,
newFlow,
selectedId,
@@ -183,6 +189,10 @@
// Used by multiplayer deploy collision warning
let deployedValue: Value | undefined = $state(undefined) // Value to diff against
let deployedLabel: string | undefined = $state(undefined) // Names it in the diff
/** The flow_version the payload in `deployedValue` came from, so the picker marks that
* one as head rather than trusting the history's first row. */
let deployedVersionShown: number | undefined = $state(undefined)
let deployedBy: string | undefined = $state(undefined) // Author
let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning
let open: boolean = $state(false) // Is confirmation modal open
@@ -336,7 +346,7 @@
// a draft, else the load-time head. This catches both a concurrent deploy
// (head moved since open) AND a stale draft reopened after a deploy (head ==
// load-time head, but the draft was forked from an older version).
const base = draftBaseVersion ?? version
const base = draftBaseVersion ?? (version != null ? String(version) : undefined)
if (base === undefined) {
return
}
@@ -347,7 +357,7 @@
path: initialPath
})
onLatest = base === flowVersion?.id
onLatest = flowVersion != null && base === String(flowVersion.id)
} else {
onLatest = true
}
@@ -463,12 +473,19 @@
}
}
}
async function syncWithDeployed() {
async function syncWithDeployed(opening?: number) {
const flow = await FlowService.getFlowByPath({
workspace: opWorkspace!,
path: initialPath,
// The draft row's own path, not `initialPath` — the route re-seeds that from
// the draft's `draft_path` so the topbar shows the pending name, which after
// someone renames the item still names the old location. Comparing against
// that fetches the row left behind there instead of the live one.
path: userDraftPath || initialPath,
withStarredInfo: true
})
// A superseded opening must not write these: the current one would then render
// and offer Take latest against the older head.
if (opening != null && !diffDrawer?.ownsOpening(opening)) return
deployedValue = replaceFalseWithUndefined({
...flow,
edited_at: undefined,
@@ -476,6 +493,10 @@
workspace_id: undefined
})
deployedBy = flow.edited_by
deployedVersionShown = flow.version_id
// Names the deployed side of the diff. Without it the reader is shown two panes
// and told nothing about what the left one is.
deployedLabel = `Deployed${flow.version_id != null ? ` ${flow.version_id}` : ''}${flow.edited_by ? ` by ${flow.edited_by}` : ''} · latest`
}
async function saveFlow(deploymentMsg?: string, toDeploy?: DraftChangesToDeploy): Promise<void> {
@@ -1115,21 +1136,117 @@
}
}
async function openDiffDrawer() {
/** Deployed versions for the diff picker, newest first. Best-effort: losing the
* list costs the picker, not the diff. */
/** Throws: the drawer says so and lets the reader ask for the same page again. */
async function fetchVersionPage(page: number) {
const path = userDraftPath || initialPath
if (!opWorkspace || !path) return undefined
{
const history = await FlowService.getFlowHistory({
workspace: opWorkspace,
path,
page,
perPage: VERSION_PAGE_SIZE
})
// Head is the version the payload beside this list came from, not whatever the
// history now leads with: a deploy landing between the two fetches would
// otherwise label the shown (older) value as the latest.
const head = deployedVersionShown ?? history[0]?.id
// No ordinal: the list arrives a page at a time, so a number counted within one
// would rename versions as more load.
return history.map((h) => {
const detail = [
h.created_by,
h.created_at ? new Date(h.created_at).toLocaleString() : undefined,
h.deployment_msg
].filter(Boolean)
const isHead = h.id === head
return {
id: String(h.id),
label: `${h.id}${isHead ? ' · latest' : ''}`,
subtitle: detail.length ? detail.join(' · ') : undefined,
isHead
}
})
}
}
/** The first page, best-effort: losing it costs the picker, not the diff. */
async function deployedVersionOptions() {
try {
return await fetchVersionPage(1)
} catch {
return undefined
}
}
/** Hands the drawer the next page each time the reader asks for one. The page number
* belongs to this item's history, so it lives here — and only moves once a page has
* actually arrived, or a failed request would skip it. */
function moreVersionsLoader() {
let loaded = 1
return async () => {
const page = await fetchVersionPage(loaded + 1)
loaded += 1
return page
}
}
/** The opening this editor claimed last. A path change remounts this editor while the
* drawer stays mounted, so its teardown hands that opening back rather than leaving
* the drawer on the item the user left. */
let lastOpening: number | undefined = undefined
onDestroy(() => {
if (lastOpening != null) diffDrawer?.abandonOpening(lastOpening)
})
export async function openDiffDrawer() {
if (!savedFlow) return
await syncWithDeployed()
// The fetches below are awaited, so a reopen (or a path change, which remounts
// this editor but not the drawer) while they run must not have the older one
// land last. The drawer counts the openings for that reason.
const opening = diffDrawer?.beginOpening()
lastOpening = opening
if (opening == null) return
await syncWithDeployed(opening)
const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
diffDrawer?.openDrawer()
// Blanking the drawer belongs to the opening that will fill it.
if (!diffDrawer?.ownsOpening(opening)) return
diffDrawer.openDrawer(opening)
const currentFlow = flowStore.val
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedFlow,
current: {
...currentFlow,
path: $pathStore,
draft_triggers: currentDraftTriggers
}
})
const versions = await deployedVersionOptions()
if (!diffDrawer?.ownsOpening(opening)) return
diffDrawer.setDiff(
{
mode: 'normal',
deployed: deployedValue ?? savedFlow,
deployedLabel,
versions,
loadMoreVersions: moreVersionsLoader(),
onTakeLatest,
draftBase: draftBaseVersion,
deployedHead: deployedVersionShown != null ? String(deployedVersionShown) : undefined,
loadVersion: async (id) => {
const v = await FlowService.getFlowVersion({
workspace: opWorkspace!,
version: Number(id)
})
return replaceFalseWithUndefined({
...v,
edited_at: undefined,
edited_by: undefined,
workspace_id: undefined
})
},
current: {
...currentFlow,
path: $pathStore,
draft_triggers: currentDraftTriggers
}
},
opening
)
}
let flowCopilotContext: FlowCopilotContext = $state({
@@ -1374,6 +1491,7 @@
{confirmCallback}
bind:open
{diffDrawer}
claimOpening={() => (lastOpening = diffDrawer?.beginOpening())}
bind:deployedValue
currentValue={flowStore.val}
/>
+30 -7
View File
@@ -8,12 +8,19 @@
import { updateItemPathAndSummary, checkFlowOnBehalfOf } from './moveRenameManager'
import Label from './Label.svelte'
import TextInput from './text_input/TextInput.svelte'
import { FlowService, ScriptService, type TriggersCount } from '$lib/gen'
import { DraftService, FlowService, ScriptService, type TriggersCount } from '$lib/gen'
const dispatch = createEventDispatcher()
type Kind = 'script' | 'resource' | 'schedule' | 'variable' | 'flow' | 'app'
/** The address the move endpoint takes: where a draft-only item's row lives, which
* `initialPath` (what the user sees and edits) need not equal. Empty for a deployed
* item, which is addressed by `initialPath` instead. */
let storagePath = $state('')
let rawApp = $state(false)
let draftOnly = $derived(storagePath !== '')
let kind = $state<Kind>('flow')
let initialPath = $state('')
let initialSummary = $state('')
@@ -66,21 +73,31 @@
})
let attachedTotal = $derived(attachedSummary.reduce((s, { count }) => s + count, 0))
/** `draft` marks an item that exists only as the caller's draft: pass the
* generated path its draft row sits at, and `initialPath_l` is then the name
* the user sees. Nothing is deployed, so there are no triggers to cascade
* and no on-behalf-of identity to warn about. */
export async function openDrawer(
initialPath_l: string,
summary_l: string | undefined,
kind_l: Kind
kind_l: Kind,
draft?: { storagePath: string; rawApp?: boolean }
) {
kind = kind_l
path = undefined
dirtyPath = false
onBehalfOfEmail = undefined
attachedTriggers = undefined
storagePath = draft?.storagePath ?? ''
rawApp = draft?.rawApp ?? false
initialPath = initialPath_l
initialSummary = summary_l ?? ''
summary = summary_l
loadOwner()
drawer.openDrawer()
if (draftOnly) {
return
}
if (kind === 'flow') {
onBehalfOfEmail = await checkFlowOnBehalfOf($workspaceStore!, initialPath_l)
}
@@ -103,11 +120,18 @@
}
function loadOwner() {
own = isOwner(initialPath, $userStore!, $workspaceStore!)
own = isOwner(draftOnly ? storagePath : initialPath, $userStore!, $workspaceStore!)
}
async function updatePath() {
if (kind === 'flow' || kind === 'script' || kind === 'app') {
if (draftOnly && (kind === 'flow' || kind === 'script' || kind === 'app')) {
await DraftService.moveDraft({
workspace: $workspaceStore!,
kind: kind === 'app' && rawApp ? 'raw_app' : kind,
path: storagePath,
requestBody: { new_path: path ?? '', summary: summary ?? '' }
})
} else if (kind === 'flow' || kind === 'script' || kind === 'app') {
await updateItemPathAndSummary({
workspace: $workspaceStore!,
kind,
@@ -142,9 +166,8 @@
<div class="flex flex-wrap gap-x-3 gap-y-1 mt-1">
{#each attachedSummary as { label, count } (label)}
<span class="text-xs"
><span class="font-mono font-semibold">{count}</span> {label}{count === 1
? ''
: 's'}</span
><span class="font-mono font-semibold">{count}</span>
{label}{count === 1 ? '' : 's'}</span
>
{/each}
</div>
@@ -20,6 +20,7 @@
import { useActingUser } from '$lib/actingUser.svelte'
import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import DraftConflictAlert from './DraftConflictAlert.svelte'
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
import { onUserInput } from '$lib/userDraftEditGate'
@@ -261,6 +262,54 @@
)
const anyDirty = $derived(dirtyWorkspaces.length > 0)
/** The server refused this tab's autosave because the row moved under it: another tab, or the
* AI chat, which writes these drafts too. Nothing typed here reaches the server until the user
* picks a version, and the unsaved-changes banner says the opposite — that the edits are held
* as a draft — so without this they are told their work is safe while it is being dropped. */
const draftConflict = $derived(
selected && initialPath
? UserDraftDbSyncer.getConflict({
workspace: selected,
itemKind: 'resource',
path: initialPath
}).conflict
: undefined
)
async function resolveDraftConflict(keepMine: boolean): Promise<void> {
const ws = selected
const p = initialPath
if (!ws || !p) return
const query = { workspace: ws, itemKind: 'resource' as const, path: p }
if (keepMine) {
// Forced, so it goes over the row that refused us, and its response reseeds
// `last_sync` so the next ordinary save is conditional again.
const mine = states[ws]?.draft
if (mine) await UserDraftDbSyncer.overwrite({ ...query, value: $state.snapshot(mine) })
return
}
// Taking theirs: drop the refused payload first so no later flush can send it, then read
// what the server holds and seed that in, which is also what gives this tab a baseline.
UserDraftDbSyncer.dropPending(query)
UserDraftDbSyncer.clearConflict(query)
const r = await ResourceService.getResource({ workspace: ws, path: p, getDraft: true })
const deployedState: ResourceState = {
path: r.path,
args: (r.value ?? {}) as Record<string, any>,
description: r.description ?? '',
labels: r.labels ?? undefined,
wsSpecific: r.ws_specific ?? false
}
initialStates[ws] = structuredClone(deployedState)
UserDraftDbSyncer.recordRemoteSync(query, (r as any).draft_saved_at)
UserDraft.seed(
'resource',
p,
((r as any).draft as ResourceState | undefined) ?? deployedState,
{ workspace: ws }
)
}
// The syncer owns the list-page `*` hint; the editor only CLEARS it when a
// workspace is at the deployed baseline (so a draft discarded elsewhere
// vanishes on reopen). Never SET here. See VariableEditor for the full note.
@@ -531,6 +580,13 @@
<div>
<div class="flex flex-col gap-6 pb-2">
{#if draftConflict}
<DraftConflictAlert
onReload={() => resolveDraftConflict(false)}
onOverwrite={() => resolveDraftConflict(true)}
/>
{/if}
{#if otherDirty.length > 0}
<Alert type="warning" title="Editing multiple workspaces">
You are going to edit the value in: {otherDirty.join(', ')}
+143 -17
View File
@@ -13,6 +13,7 @@
WorkerService
} from '$lib/gen'
import { inferArgs } from '$lib/infer'
import { VERSION_PAGE_SIZE } from '$lib/components/diff_drawer'
import {
initialCode,
canHavePreprocessor,
@@ -87,7 +88,7 @@
import { writable } from 'svelte/store'
import { defaultScriptLanguages, processLangs } from '$lib/scripts'
import DefaultScripts from './DefaultScripts.svelte'
import { getContext, onMount, setContext, tick, untrack } from 'svelte'
import { getContext, onDestroy, onMount, setContext, tick, untrack } from 'svelte'
import EditorHeader from './EditorHeader.svelte'
import ScriptSettingsBadges from './ScriptSettingsBadges.svelte'
import Badge from './common/badge/Badge.svelte'
@@ -119,6 +120,8 @@
fullyLoaded = true,
initialPath = $bindable(''),
userDraftPath = '',
onTakeLatest = undefined,
draftBaseHash = undefined,
autosaveWorkspace = undefined,
autosavePath = undefined,
template = $bindable('script'),
@@ -558,10 +561,13 @@
try {
if (initialPath && initialPath != '') {
// The row's path, not `initialPath`: that is the typed path, where a
// staged rename has nothing deployed, and a missing head would deploy
// on a base that already has a child.
actual_parent_hash = (
await ScriptService.getScriptLatestVersion({
workspace: opWorkspace!,
path: initialPath
path: userDraftPath || initialPath
})
)?.script_hash
}
@@ -573,9 +579,15 @@
// But if we specify parent_hash that is already used, than we get error
// In order to fix it we make sure that client's understanding of parent_hash
// is aligns with understanding of backend.
if (actual_parent_hash == undefined || script.parent_hash == actual_parent_hash) {
//
// A draft with no base (one predating `draft.base`) is compared on the head this
// editor loaded, as the flow and raw-app guards are: comparing `undefined` would
// open the confirmation on every deploy, telling the user a version landed while
// they were editing when none had.
const baseHash = script.parent_hash ?? deployedScriptHash
if (actual_parent_hash == undefined || baseHash == actual_parent_hash) {
// Handle directly
await editScript(stay, script.parent_hash!, deployMsg)
await editScript(stay, (script.parent_hash ?? actual_parent_hash)!, deployMsg)
} else {
// Fetch entire script, since we need it to show Diff
await syncWithDeployed()
@@ -605,13 +617,20 @@
}
}
async function syncWithDeployed() {
async function syncWithDeployed(opening?: number) {
const latestScript = await ScriptService.getScriptByPath({
workspace: opWorkspace!,
path: initialPath,
// The draft row's own path, not `initialPath` — that one tracks the path
// the user has typed, so after someone renames the item it still names
// the old location and this would compare against the archived row left
// there. Resolving by the row is what lets the diff show the rename.
path: userDraftPath || initialPath,
withStarredInfo: true
})
// A superseded opening must not write these: the current one would then render
// and offer Take latest against the older head.
if (opening != null && !diffDrawer?.ownsOpening(opening)) return
deployedValue = replaceFalseWithUndefined({
...latestScript,
workspace_id: undefined,
@@ -782,7 +801,7 @@
// + `tick()` first so the last keystrokes reach the bindable before the
// syncer flushes. No toast — the AutosaveIndicator narrates the result, and
// `flush` never rejects (postSave routes errors to the failures map).
async function saveDraft(): Promise<void> {
export async function saveDraft(): Promise<void> {
if (!opWorkspace || !userDraftPath) return
editor?.flushPendingChanges()
await tick()
@@ -811,11 +830,90 @@
// top-bar button (rendered independently of the session pane), not here.
const inSessionPane = !!getContext('aiChatManager')
async function openDiffDrawer() {
/** Names the version on the deployed side of the diff. Without it the reader is
* shown two panes and told nothing about what the left one is — which matters
* most in the stale-draft case, where the whole question is "whose version am I
* about to overwrite". */
function deployedVersionLabel(deployed: any): string | undefined {
if (!deployed?.hash) return undefined
// `deployedBy` is captured in `syncWithDeployed` before it strips `created_by`.
// Only used when the version list is unavailable; otherwise the picker labels it.
return `Deployed${deployedBy ? ` by ${deployedBy}` : ''} · latest`
}
/** Deployed versions to offer in the diff picker, newest first. Best-effort: a
* failure here costs the picker, not the diff, so the drawer still opens on the
* head. `deployment_msg` is all the history endpoint carries besides the hash. */
/** Throws: the drawer says so and lets the reader ask for the same page again. */
async function fetchVersionPage(headHash: string | undefined, page: number) {
if (!opWorkspace || !userDraftPath) return undefined
{
const history = await ScriptService.getScriptHistoryByPath({
workspace: opWorkspace,
path: userDraftPath,
page,
perPage: VERSION_PAGE_SIZE
})
// The hash identifies the version — it is what the API and the CLI speak — and
// who deployed it drops to the subtitle. No ordinal: the list arrives a page at
// a time, so a number counted within one would rename versions as more load.
return history.map((h) => {
const isHead = h.script_hash === headHash
const detail = [
h.created_by,
h.created_at ? new Date(h.created_at).toLocaleString() : undefined,
h.deployment_msg
].filter(Boolean)
return {
id: h.script_hash,
label: `${h.script_hash.slice(0, 8)}${isHead ? ' · latest' : ''}`,
subtitle: detail.length ? detail.join(' · ') : undefined,
isHead
}
})
}
}
/** The first page, best-effort: losing it costs the picker, not the diff. */
async function deployedVersionOptions(headHash: string | undefined) {
try {
return await fetchVersionPage(headHash, 1)
} catch {
return undefined
}
}
/** Hands the drawer the next page each time the reader asks for one. The page number
* belongs to this item's history, so it lives here — and only moves once a page has
* actually arrived, or a failed request would skip it. */
function moreVersionsLoader(headHash: string | undefined) {
let loaded = 1
return async () => {
const page = await fetchVersionPage(headHash, loaded + 1)
loaded += 1
return page
}
}
/** The opening this editor claimed last. A path change remounts this editor while the
* drawer stays mounted, so its teardown hands that opening back rather than leaving
* the drawer on the item the user left. */
let lastOpening: number | undefined = undefined
onDestroy(() => {
if (lastOpening != null) diffDrawer?.abandonOpening(lastOpening)
})
export async function openDiffDrawer() {
if (!savedScript) {
return
}
await syncWithDeployed()
// The fetches below are awaited, so a reopen (or a path change, which remounts
// this editor but not the drawer) while they run must not have the older one
// land last. The drawer counts the openings for that reason.
const opening = diffDrawer?.beginOpening()
lastOpening = opening
if (opening == null) return
await syncWithDeployed(opening)
const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
@@ -832,13 +930,40 @@
}
if (current.assets && !current.assets.length) delete current.assets
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'normal',
deployed,
draft: savedScript['draft'],
current
})
// Blanking the drawer belongs to the opening that will fill it.
if (!diffDrawer?.ownsOpening(opening)) return
diffDrawer.openDrawer(opening)
const headHash = (deployed as { hash?: string } | undefined)?.hash
const versions = await deployedVersionOptions(headHash)
if (!diffDrawer?.ownsOpening(opening)) return
diffDrawer.setDiff(
{
mode: 'normal',
deployed,
deployedLabel: deployedVersionLabel(deployed),
versions,
loadMoreVersions: moreVersionsLoader(headHash),
onTakeLatest,
draftBase: draftBaseHash,
deployedHead: headHash,
loadVersion: async (hash) => {
const v = await ScriptService.getScriptByHash({ workspace: opWorkspace!, hash })
return replaceFalseWithUndefined({
...v,
workspace_id: undefined,
created_at: undefined,
created_by: undefined,
extra_perms: undefined,
lock: undefined,
lock_error_logs: undefined,
parent_hashes: undefined
})
},
draft: savedScript['draft'],
current
},
opening
)
}
function computeDropdownItems(
@@ -1145,6 +1270,7 @@
{confirmCallback}
bind:open
{diffDrawer}
claimOpening={() => (lastOpening = diffDrawer?.beginOpening())}
bind:deployedValue
currentValue={script}
/>
@@ -2041,7 +2167,7 @@
args={hasPreprocessor && selectedInputTab !== 'preprocessor' ? {} : args}
isDeployed={savedScript && savedScript?.no_deployed !== true}
schema={script.schema}
runnableVersion={script.parent_hash}
runnableVersion={deployedScriptHash}
onDeployTrigger={handleDeployTrigger}
/>
@@ -23,6 +23,8 @@
import { useActingUser } from '$lib/actingUser.svelte'
import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte'
import LocalDraftBanner from './LocalDraftBanner.svelte'
import DraftConflictAlert from './DraftConflictAlert.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { isEncryptedDraftValue } from '$lib/encryptedDraft'
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
@@ -123,6 +125,62 @@
Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws]))
)
/** The server refused this tab's autosave because the row moved under it: another tab, or the
* AI chat, which writes these drafts too. Nothing typed here reaches the server until the user
* picks a version, and the unsaved-changes banner says the opposite — that the edits are held
* as a draft — so without this they are told their work is safe while it is being dropped. */
const draftConflict = $derived(
edit && selected && editPath
? UserDraftDbSyncer.getConflict({
workspace: selected,
itemKind: 'variable',
path: editPath
}).conflict
: undefined
)
async function resolveDraftConflict(keepMine: boolean): Promise<void> {
const ws = selected
const p = editPath
if (!ws || !p) return
const query = { workspace: ws, itemKind: 'variable' as const, path: p }
if (keepMine) {
// Forced, so it goes over the row that refused us, and its response reseeds
// `last_sync` so the next ordinary save is conditional again.
const mine = states[ws]?.draft
if (mine) await UserDraftDbSyncer.overwrite({ ...query, value: $state.snapshot(mine) })
return
}
// Taking theirs: drop the refused payload first so no later flush can send it, then read
// what the server holds and seed that in, which is also what gives this tab a baseline.
UserDraftDbSyncer.dropPending(query)
UserDraftDbSyncer.clearConflict(query)
const v = await VariableService.getVariable({
workspace: ws,
path: p,
decryptSecret: false,
getDraft: true
})
const deployedState: VariableState = {
path: v.path,
variable: {
value: v.value ?? '',
is_secret: v.is_secret,
description: v.description ?? ''
},
labels: v.labels ?? undefined,
wsSpecific: v.ws_specific ?? false
}
initialStates[ws] = structuredClone(deployedState)
UserDraftDbSyncer.recordRemoteSync(query, (v as any).draft_saved_at)
UserDraft.seed(
'variable',
p,
((v as any).draft as VariableState | undefined) ?? deployedState,
{ workspace: ws }
)
}
// The list-page `*` hint is owned by UserDraftDbSyncer (set on save, cleared
// on delete). The editor only CLEARS it — a workspace at the deployed
// baseline has no draft, so drop any stale hint (this is how a draft
@@ -317,6 +375,12 @@
on:close={drawer?.closeDrawer}
>
{#snippet banner()}
{#if draftConflict}
<DraftConflictAlert
onReload={() => resolveDraftConflict(false)}
onOverwrite={() => resolveDraftConflict(true)}
/>
{/if}
<LocalDraftBanner
show={edit && selectedDirty}
reserveSpace={edit}
@@ -8,7 +8,7 @@
import InputValue from '../helpers/InputValue.svelte'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
let editor = $state()
let editor: HTMLElement | undefined = $state()
let quill: any = $state()
interface Props {
id: string
@@ -24,7 +24,7 @@
toolbarOptions = [
[{ header: 1 }, { header: 2 }, 'blockquote', 'link', 'image', 'video'],
['bold', 'italic', 'underline', 'strike'],
[{ list: 'ordered' }, { list: 'ordered' }],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ align: [] }],
['clean']
]
@@ -42,6 +42,7 @@
async function loadQuill() {
const { default: Quill } = await import('quill')
if (!editor) return
quill = new Quill(editor, {
modules: {
@@ -51,33 +52,73 @@
placeholder: placeholder
})
if (defaultValue) {
quill.root.innerHTML = defaultValue
}
quill.on('text-change', function (delta, oldDelta, source) {
setOutput()
})
if (defaultValue) {
setHtml(defaultValue)
}
}
// Load HTML through Quill's clipboard converter rather than assigning `root.innerHTML`:
// Quill only keeps markup it has a blot for, and writes to the DOM bypass that mapping.
// Quill 2 renders every list as `<ol>`, so a `<ul>` from stored content would be dropped
// outright instead of becoming a bullet list.
function setHtml(html: string | undefined) {
quill.setContents(quill.clipboard.convert({ html: html ?? '', text: '' }))
}
$componentControl[untrack(() => id)] = {
setValue(nvalue: string) {
if (quill) {
quill.root.innerHTML = nvalue
setHtml(nvalue)
setOutput()
}
}
}
// Quill 2 keeps every list in an `<ol>` and marks the kind with `data-list` on each
// item (`bullet`, `ordered`, `checked`, `unchecked`), plus a `span.ql-ui` that only its
// stylesheet renders. Consumers of the output (an HTML component, stored content) render
// it outside that stylesheet, where that markup reads as a numbered list. Emit the
// standard shape instead: `<ul>`/`<ol>` runs, checklists as `<ul data-checked>`, which
// is what the clipboard converter reads back on load (`matchList`).
function toStandardHtml(root: HTMLElement): string {
const clone = root.cloneNode(true) as HTMLElement
clone.querySelectorAll('span.ql-ui').forEach((s) => s.remove())
clone.querySelectorAll('ol').forEach((ol) => {
const lists: HTMLElement[] = []
for (const li of Array.from(ol.children)) {
const kind = li.getAttribute('data-list')
li.removeAttribute('data-list')
const tag = kind === 'ordered' ? 'ol' : 'ul'
const checked = kind === 'checked' ? 'true' : kind === 'unchecked' ? 'false' : null
const last = lists.at(-1)
if (
last?.tagName.toLowerCase() !== tag ||
last.getAttribute('data-checked') !== checked
) {
const list = document.createElement(tag)
if (checked !== null) list.setAttribute('data-checked', checked)
lists.push(list)
}
lists.at(-1)!.appendChild(li)
}
ol.replaceWith(...lists)
})
return clone.innerHTML
}
function setOutput() {
if (quill) {
outputs?.result.set(quill.root.innerHTML)
outputs?.result.set(toStandardHtml(quill.root))
}
}
function handleDefault(defaultValue: string | undefined) {
if (quill) {
quill.root.innerHTML = defaultValue
setHtml(defaultValue)
setOutput()
}
}
@@ -85,7 +85,8 @@
loadedFromDraft = false,
othersDraftsCount = 0,
onOpenOthersDrafts,
onRestore
onRestore,
onDeploy
}: AppEditorProps = $props()
migrateApp(untrack(() => app))
@@ -893,6 +894,7 @@
{othersDraftsCount}
{onOpenOthersDrafts}
{onRestore}
{onDeploy}
{policy}
{fromHub}
bind:this={appEditorHeader}
@@ -32,7 +32,7 @@
Zap,
Globe
} from 'lucide-svelte'
import { getContext, untrack } from 'svelte'
import { getContext, onDestroy, untrack } from 'svelte'
import { orderedJsonStringify, type Value, replaceFalseWithUndefined } from '../../../utils'
import type { App, AppEditorContext, AppViewerContext } from '../types'
import { toStatic } from '../utils'
@@ -110,6 +110,10 @@
// (not `on:restore` forwarding): forwarding a `createEventDispatcher`
// event up through these runes-mode components silently drops it.
onRestore?: (restoredApp: any) => void
// Fired after a successful deploy, which keeps this editor open: `version` is what
// the deploy wrote, for the next draft's fork base, and `head` what is deployed
// now, with its author and time.
onDeploy?: (e: { version?: number; head?: number; headBy?: string; headAt?: string }) => void
}
let {
@@ -137,7 +141,8 @@
loadedFromDraft = false,
othersDraftsCount = 0,
onOpenOthersDrafts,
onRestore
onRestore,
onDeploy
}: Props = $props()
/** Mirror of the path the user is editing in the pen popover. Initialized
@@ -329,13 +334,24 @@
}
}
async function syncWithDeployed() {
/** The opening this editor claimed last. A path change remounts this editor while the
* drawer stays mounted, so its teardown hands that opening back rather than leaving
* the drawer on the app the user left. */
let lastOpening: number | undefined = undefined
onDestroy(() => {
if (lastOpening != null) diffDrawer?.abandonOpening(lastOpening)
})
async function syncWithDeployed(opening?: number) {
const deployedApp = await AppService.getAppByPath({
workspace: $workspaceStore!,
path: $appPath!,
withStarredInfo: true
})
// A superseded opening must not write these: the current one would then render
// against the older deployed value.
if (opening != null && !diffDrawer?.ownsOpening(opening)) return
deployedBy = deployedApp.created_by
// Strip off extra information
@@ -351,7 +367,7 @@
async function updateApp(npath: string) {
policy = await updatePolicy($app, policy)
await AppService.updateApp({
const deployed = await AppService.updateApp({
workspace: $workspaceStore!,
path: $appPath!,
requestBody: {
@@ -377,16 +393,26 @@
custom_path: customPath,
labels: $state.snapshot(labels)
}
const appHistory = await AppService.getAppHistoryByPath({
const head = await AppService.getAppLatestVersion({
workspace: $workspaceStore!,
path: npath
})
version = appHistory[0]?.version
// Re-pin the fork base to the just-deployed head: the editor stays open, so a
// `version` is what is deployed now, which is the deploy guard's fallback head; the
// deploy's own answer is the base, and the two differ when another landed beside it.
version = head?.version
// Re-pin the fork base to the version just written: the editor stays open, so a
// follow-up deploy (or a new edit) would otherwise compare against the now-
// superseded base and falsely warn. parent_version is in
// DRAFT_COMPARE_IGNORED_FIELDS, so this write can't spawn a spurious draft.
if ($app) $app.parent_version = version
if ($app) $app.parent_version = deployed.version
// The route owns the pair the out-of-date prompt reads, and this editor stays open
// across the deploy, so hand both over rather than leaving it on the old ones.
onDeploy?.({
version: deployed.version,
head: version,
headBy: head?.created_by,
headAt: head?.created_at
})
closeSaveDrawer()
sendUserToast('App deployed successfully')
@@ -430,6 +456,9 @@
}
let onLatest = $state(true)
/** The last comparison could not read the head, so the confirmation it raises is
* caution and not an observed deploy. Cleared by the next reading comparison. */
let headUnknown = $state(false)
async function compareVersions() {
// Compare the draft's pinned fork base (`$app.parent_version`) against the
// current head when editing a draft, else the load-time head. Catches both a
@@ -445,9 +474,14 @@
path: $appPath
})
onLatest = appVersion?.version === undefined || base === appVersion?.version
headUnknown = false
} catch (e) {
console.error('Error comparing versions', e)
onLatest = true
// The head is what this compares against, so an unanswered read is not
// evidence of being current: confirm, as an unclaimable deploy does, and say
// that is why rather than claiming a version that was never seen.
onLatest = false
headUnknown = true
}
}
@@ -624,23 +658,33 @@
if (!savedApp || newApp) {
return
}
// The fetch below is awaited, so a reopen (or a path change, which remounts
// this editor but not the drawer) while it runs must not have the older one
// land last. The drawer counts the openings for that reason.
const opening = diffDrawer?.beginOpening()
lastOpening = opening
if (opening == null) return
// deployedValue should be syncronized when we open Diff
await syncWithDeployed()
await syncWithDeployed(opening)
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedApp,
current: {
summary: $summary,
value: $app,
path: newEditedPath || savedApp.path,
policy,
custom_path: customPath,
labels
}
})
if (!diffDrawer?.ownsOpening(opening)) return
diffDrawer.openDrawer(opening)
diffDrawer.setDiff(
{
mode: 'normal',
deployed: deployedValue ?? savedApp,
current: {
summary: $summary,
value: $app,
path: newEditedPath || savedApp.path,
policy,
custom_path: customPath,
labels
}
},
opening
)
},
disabled: !savedApp || newApp
},
@@ -721,6 +765,8 @@
{confirmCallback}
bind:open
{diffDrawer}
claimOpening={() => (lastOpening = diffDrawer?.beginOpening())}
{headUnknown}
bind:deployedValue
currentValue={{
summary: $summary,
@@ -759,33 +805,41 @@
if (!savedApp || newApp) {
return
}
// The other entry point into the same drawer, so it takes an opening too.
const opening = diffDrawer?.beginOpening()
lastOpening = opening
if (opening == null) return
// deployedValue should be syncronized when we open Diff
await syncWithDeployed()
await syncWithDeployed(opening)
if (!diffDrawer?.ownsOpening(opening)) return
saveDrawerOpen = false
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedApp,
current: {
summary: $summary,
value: $app,
path: newEditedPath || savedApp.path,
policy,
custom_path: customPath,
labels
},
button: {
text: 'Looks good, deploy',
onClick: () => {
if (newApp) {
createApp(newEditedPath)
} else {
handleUpdateApp(newEditedPath)
diffDrawer.openDrawer(opening)
diffDrawer.setDiff(
{
mode: 'normal',
deployed: deployedValue ?? savedApp,
current: {
summary: $summary,
value: $app,
path: newEditedPath || savedApp.path,
policy,
custom_path: customPath,
labels
},
button: {
text: 'Looks good, deploy',
onClick: () => {
if (newApp) {
createApp(newEditedPath)
} else {
handleUpdateApp(newEditedPath)
}
}
}
}
})
},
opening
)
}}
>
<div class="flex flex-row gap-2 items-center">
@@ -818,6 +872,7 @@
{setPublishState}
appPath={$appPath}
{onLatest}
{headUnknown}
{savedApp}
bind:summary={$summary}
bind:customPath
@@ -35,6 +35,7 @@
appPath,
customPath = $bindable(),
onLatest,
headUnknown = false,
savedApp,
summary = $bindable(),
deploymentMsg = $bindable(),
@@ -54,6 +55,9 @@
appPath: string
customPath: string | undefined
onLatest: boolean
/** The comparison behind `onLatest` could not read the deployed head, so the warning
* is caution rather than an observed newer version. */
headUnknown?: boolean
savedApp: any
summary: string
deploymentMsg: string | undefined
@@ -239,8 +243,15 @@
</script>
{#if !onLatest}
<Alert title="You're not on the latest app version. " type="warning">
By deploying, you may overwrite changes made by other users. Press 'Deploy' to see diff.
<Alert
title={headUnknown
? 'Could not check the deployed version. '
: "You're not on the latest app version. "}
type="warning"
>
{headUnknown
? "Deploying may overwrite changes made by other users. Press 'Deploy' to see diff."
: "By deploying, you may overwrite changes made by other users. Press 'Deploy' to see diff."}
</Alert>
<div class="py-2"></div>
{/if}
@@ -1,7 +1,7 @@
import { base } from "$lib/base"
import { workspaceStore } from "$lib/stores"
import { get } from "svelte/store"
import { base } from '$lib/base'
import { workspaceStore } from '$lib/stores'
import { get } from 'svelte/store'
export function computeSecretUrl(secretUrl: string) {
return `${window.location.origin}${base}/public/${get(workspaceStore)}/${secretUrl}`
return `${window.location.origin}${base}/public/${get(workspaceStore)}/${secretUrl}`
}
@@ -183,6 +183,11 @@ export interface AppEditorProps {
// AppEditorHeader as a callback prop rather than `on:restore` forwarding,
// which does not propagate through these runes-mode components.
onRestore?: (restoredApp: any) => void
// Fired after a successful deploy, which keeps this editor open: `version` is what
// the deploy wrote, for the next draft's fork base, and `head` what is deployed now,
// with its author and time. `version` and `head` differ when another deploy landed
// beside this one, which is when the out-of-date prompt has something to say.
onDeploy?: (e: { version?: number; head?: number; headBy?: string; headAt?: string }) => void
}
export type App = {
@@ -71,7 +71,9 @@
style={bgStyle}
>
<div class="flex flex-row items-center">
<div class="flex h-8 w-8 items-center justify-center rounded-full">
<!-- shrink-0: the badge's min-content width is the 16px icon, so without it a
narrow container squeezes the circle into an oval. -->
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
<SvelteComponent
class={twMerge(classes[type].iconClass, iconClass)}
style={iconStyle}
@@ -172,7 +172,11 @@
<div class="flex min-h-full items-center justify-center p-4">
<div
class={classNames(
'relative transform overflow-hidden rounded-lg bg-surface px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6',
// `w-full max-w-lg` unprefixed, not `sm:`-only: below 640px the panel would
// otherwise be sized by its content's min-content width, and since the
// wrapper centres it without scrolling, anything wider than the viewport
// gets clipped on both sides with the left half unreachable.
'relative transform overflow-hidden rounded-lg bg-surface px-4 pt-5 pb-4 text-left shadow-xl transition-all w-full max-w-lg sm:my-8 sm:p-6',
open
? 'ease-out duration-300 opacity-100 translate-y-0 sm:scale-100'
: 'ease-in duration-200 opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95'
@@ -180,13 +184,20 @@
>
<div class="flex">
{#if showIcon}
<!-- shrink-0: the badge is a flex item whose min-content width is the
24px icon, so without it a narrow dialog squeezes the circle into a
24x48 oval instead of wrapping the text beside it. -->
<div
class={`flex h-12 w-12 items-center justify-center rounded-full ${theme[type].classes.iconWrapper}`}
class={`flex h-12 w-12 shrink-0 items-center justify-center rounded-full ${theme[type].classes.iconWrapper}`}
>
<Icon class={theme[type].classes.icon} />
</div>
{/if}
<div class={twMerge('ml-0 text-left flex-1 ', showIcon ? 'ml-4' : '')}>
<!-- min-w-0: `flex-1` alone keeps `min-width: auto`, so a body containing
one long unbreakable string (a path list, a URL) sizes this column by
that string and pushes it out of the panel — and any `truncate` inside
never engages. -->
<div class={twMerge('ml-0 text-left flex-1 min-w-0', showIcon ? 'ml-4' : '')}>
<h3 class="text-lg font-medium text-primary">
{title}
</h3>
@@ -11,6 +11,15 @@
confirmCallback: () => void
deployedBy?: string | undefined
open?: boolean
/** Takes a drawer opening on the host editor's behalf, so the diff below is one the
* editor can take back down. Override anyway deploys that editor's content: without
* this the drawer would outlive it (a relocation remounts the editor) and deploy it
* at a path it has left. Omit where the host cannot be remounted under the drawer. */
claimOpening?: () => number | undefined
/** The host could not read the deployed head, so this confirmation is caution rather
* than an observed newer version: nobody may have deployed over the user, and
* `deployedBy` is then whoever wrote the head, possibly themselves. */
headUnknown?: boolean
}
let {
@@ -19,13 +28,15 @@
diffDrawer = undefined,
confirmCallback,
deployedBy = undefined,
open = $bindable(false)
open = $bindable(false),
claimOpening = undefined,
headUnknown = false
}: Props = $props()
</script>
<ConfirmationModal
{open}
title={'New version deployed by ' + deployedBy}
title={headUnknown ? 'Deploy anyway?' : 'New version deployed by ' + deployedBy}
confirmationText="Override"
on:canceled={() => {
open = false
@@ -33,7 +44,11 @@
on:confirmed={() => confirmCallback()}
>
<div class="flex flex-col w-full space-y-4">
<span>A new version was deployed while you were editing this one.</span>
<span>
{headUnknown
? 'This editor could not check whether a newer version is deployed, so it cannot tell whether this overwrites newer work.'
: 'A new version was deployed while you were editing this one.'}
</span>
{#if diffDrawer}
<Button
wrapperClasses="self-start"
@@ -44,17 +59,21 @@
return
}
open = false
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: deployedValue,
current: currentValue,
title: 'Deployed <> Current',
button: {
text: 'Override anyway',
onClick: () => confirmCallback()
}
})
const opening = claimOpening?.()
diffDrawer?.openDrawer(opening)
diffDrawer?.setDiff(
{
mode: 'simple',
original: deployedValue,
current: currentValue,
title: 'Deployed <> Current',
button: {
text: 'Override anyway',
onClick: () => confirmCallback()
}
},
opening
)
}}
>Show diff
</Button>
@@ -13,6 +13,10 @@
import type { UserDraftItemKind } from '$lib/gen'
import DraftSyncConflictModal from './DraftSyncConflictModal.svelte'
import OtherUsersDraftsModal, { type OtherDraftUser } from './OtherUsersDraftsModal.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { sendUserToast } from '$lib/toast'
import { base } from '$app/paths'
import { goto } from '$app/navigation'
import StaleDraftModal from './StaleDraftModal.svelte'
import ConfirmationModal from './ConfirmationModal.svelte'
import { OtherUserDraftLoad } from '$lib/components/otherUserDraftLoad.svelte'
@@ -38,15 +42,27 @@
draftSavedAt?: string | undefined
/** ISO timestamp of the latest deploy at this path. */
deployedAt?: string | undefined
/** Precise staleness inputs (flows/apps): the deployed version the draft was
* forked from, and the current deployed head. When both are set they drive
* `isStale` and the dedup key instead of the timestamps — exact, and stable
* across autosaves (the timestamp drifts past `deployedAt` as you keep
* editing). Absent (pre-feature drafts, scripts) ⇒ timestamp fallback. */
draftBaseVersion?: number | undefined
deployedHeadVersion?: number | undefined
/** Precise staleness inputs: the deployed version the draft forked from
* (`draft_base` on the get-by-path response) and the current deployed head,
* both as text whatever the kind. When both are set they drive `isStale` and
* the dedup key instead of the timestamps, which drift past `deployedAt` as
* you keep editing. Absent (a draft never forked from a deploy) ⇒ timestamp
* fallback. */
draftBaseVersion?: string | undefined
deployedHeadVersion?: string | undefined
/** Who deployed the head, named in the stale prompt. */
deployedBy?: string | undefined
/** Discard the draft and reload deployed (same as "Reset to deployed"). */
onLoadLatestDeploy?: () => void | Promise<void>
/** Opens the editor's Deployed↔Current diff from the stale prompt, so the
* choice between keeping and discarding is informed. Omit where the editor
* has no diff drawer; the action is then not rendered. */
onViewDiff?: () => void | Promise<void>
/** Runs before this editor follows its draft to the item's new path: the
* editor's own draft save, which materializes text the code editor still
* holds. Without it, keystrokes typed since the relocating save are lost
* to the navigation. */
onBeforeRelocate?: () => void | Promise<void>
/** Defaults to true; set to false to suppress all modals. */
enabled?: boolean
}
@@ -65,7 +81,10 @@
deployedAt = undefined,
draftBaseVersion = undefined,
deployedHeadVersion = undefined,
deployedBy = undefined,
onLoadLatestDeploy,
onViewDiff,
onBeforeRelocate,
enabled = true
}: Props = $props()
@@ -74,9 +93,9 @@
let staleAlertKey = $state<string | undefined>(undefined)
let staleModalOpen = $state(false)
// Prefer the exact version comparison (flows/apps) over the timestamp: the
// draft's pinned fork base never drifts, whereas `draftSavedAt` advances past
// `deployedAt` once you keep editing a stale draft, hiding the staleness.
// Prefer the version comparison over the timestamp for every kind that supplies
// one: `draftSavedAt` advances past `deployedAt` as you keep editing, hiding the
// staleness outright.
const useVersion = $derived(draftBaseVersion != null && deployedHeadVersion != null)
const isStale = $derived(
!!onLoadLatestDeploy &&
@@ -105,6 +124,48 @@
}
})
})
const EDITOR_SEGMENT: Partial<Record<UserDraftItemKind, string>> = {
script: 'scripts/edit',
flow: 'flows/edit',
app: 'apps/edit',
raw_app: 'apps_raw/edit'
}
// The item was moved while this editor was open: the draft row followed it
// and the save just landed there. Follow it too — the route reloads the item
// at its new path, and the stale prompt above then says what changed. Edits
// typed since that save are flushed first, so leaving this path drops none.
$effect(() => {
if (!enabled || !workspace || !path) return
const seg = EDITOR_SEGMENT[itemKind]
if (!seg) return
const query = { workspace, itemKind, path }
// The flush below saves again and can land here a second time, and a second move
// can land while it runs: the last destination reported is the one to follow.
let relocating = false
let destination: string | undefined = undefined
return UserDraftDbSyncer.onRelocated(query, async (newPath) => {
destination = newPath
if (relocating) return
relocating = true
await onBeforeRelocate?.()
await UserDraftDbSyncer.flush(query)
// `flush` resolves on a failed or rejected save as well, and leaving the
// route drops what it was carrying: stay, so the editor keeps the edits
// and its own failure indicator.
if (
UserDraftDbSyncer.getState(query).failureMessage ||
UserDraftDbSyncer.getConflict(query).conflict
) {
relocating = false
return
}
const target = destination ?? newPath
sendUserToast(`This item was moved to ${target}. You are now editing it there.`)
await goto(`${base}/${seg}/${target}`)
})
})
</script>
{#if enabled && workspace && path}
@@ -130,9 +191,14 @@
{#if onLoadLatestDeploy}
<StaleDraftModal
bind:isOpen={staleModalOpen}
{itemKind}
{draftSavedAt}
{deployedAt}
{draftBaseVersion}
{deployedHeadVersion}
{deployedBy}
{onLoadLatestDeploy}
{onViewDiff}
/>
{/if}
<ConfirmationModal
@@ -1,41 +1,66 @@
<script lang="ts">
/**
* Modal opened on editor mount when the authed user's per-user draft
* is older than the latest deployed version at the same path — i.e.
* someone else deployed a new version while the draft was sitting
* around. The user is asked to either pick up the latest deploy
* (discards the stale draft) or keep editing what they had.
*
* The parent threads `draftSavedAt` and `deployedAt` raw and the
* modal computes staleness internally; this keeps each route from
* re-implementing the comparison and the threshold (we treat a
* draft as stale only when it's strictly older — a deploy at the
* exact same instant is treated as not stale).
* The prompt for a draft that is behind: someone deployed a newer version
* of the item after the draft forked from it. Opened on every load while
* that holds (the parent computes it; see DraftEditorModals), it names the
* two versions and offers two ways out: look at the diff, or drop the draft
* for the latest deploy. Closing it is "keep editing". Taking the latest as
* the new base while keeping the edits lives in the diff drawer, where the
* user can see what they are taking.
*
* Open-state is bindable so the parent can dismiss programmatically
* (e.g. after the load-latest-deploy callback completes).
*/
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { AlertTriangle } from 'lucide-svelte'
import { AlertTriangle, GitCompare } from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
import type { UserDraftItemKind } from '$lib/gen'
type Props = {
isOpen: boolean
/** ISO timestamp the authed user's draft was saved. */
itemKind: UserDraftItemKind
/** ISO timestamp the authed user's draft was saved. Shown only when the
* versions below are unknown (a draft that predates the base). */
draftSavedAt: string | undefined
/** ISO timestamp the latest deploy at this path landed. */
deployedAt: string | undefined
/** The version the draft forked from and the deployed head, as text. */
draftBaseVersion?: string | undefined
deployedHeadVersion?: string | undefined
/** Who deployed the head. */
deployedBy?: string | undefined
/** Discards the draft and reloads the deployed payload — the route
* already has this callback for the AutosaveIndicator's "Reset to
* deployed" button; pass the same function in. */
onLoadLatestDeploy: () => void | Promise<void>
/** Opens the editor's own Deployed↔Current diff. Without it the user is
* asked to choose between keeping and discarding their draft with no way
* to see what actually differs — and after a rename the difference is
* often only the path. Omitted where the editor has no diff drawer. */
onViewDiff?: () => void | Promise<void>
}
let { isOpen = $bindable(), draftSavedAt, deployedAt, onLoadLatestDeploy }: Props = $props()
let {
isOpen = $bindable(),
itemKind,
draftSavedAt,
deployedAt,
draftBaseVersion = undefined,
deployedHeadVersion = undefined,
deployedBy = undefined,
onLoadLatestDeploy,
onViewDiff
}: Props = $props()
let loading = $state(false)
// Scripts are versioned by hash, the other kinds by a numeric version id;
// the diff picker renders them the same way.
function formatVersion(v: string): string {
return itemKind === 'script' ? v.slice(0, 8) : v
}
async function loadLatestDeploy() {
if (loading) return
loading = true
@@ -49,6 +74,13 @@
}
}
// Dismisses on the way out: the diff drawer opens behind this modal, so
// leaving it up would cover the thing the user asked to see.
async function viewDiff() {
isOpen = false
await onViewDiff?.()
}
function formatTs(ts: string | undefined): string {
if (!ts) return ''
try {
@@ -64,22 +96,38 @@
<div class="flex gap-3 items-start">
<AlertTriangle size={20} class="text-amber-500 shrink-0 mt-0.5" />
<div class="flex flex-col gap-1 text-sm text-primary">
<p>
A newer version was deployed after you started editing. Your draft is based on the older
deploy.
</p>
<p class="text-xs text-secondary">
Draft saved {formatTs(draftSavedAt)} · Deployed {formatTs(deployedAt)}
</p>
<p>A newer version was deployed after you started editing.</p>
{#if draftBaseVersion && deployedHeadVersion}
<p class="text-xs text-secondary">
Your draft is based on <span class="font-mono">{formatVersion(draftBaseVersion)}</span>
· latest is <span class="font-mono">{formatVersion(deployedHeadVersion)}</span>
{#if deployedBy}by {deployedBy}{/if}{#if deployedAt}, {formatTs(deployedAt)}{/if}
</p>
{:else}
<p class="text-xs text-secondary">
Draft saved {formatTs(draftSavedAt)} · Deployed {formatTs(deployedAt)}
</p>
{/if}
</div>
</div>
<div class="flex justify-end gap-2 mt-2">
<Button variant="default" size="sm" on:click={() => (isOpen = false)}>
Keep editing my draft
</Button>
<Button variant="contained" color="dark" size="sm" {loading} on:click={loadLatestDeploy}>
Load latest deploy
<div class="flex justify-between items-center gap-2 mt-2">
{#if onViewDiff}
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: GitCompare }}
on:click={viewDiff}
>
See what changed
</Button>
{:else}
<div></div>
{/if}
<!-- Red: this replaces the draft with the latest deploy. Closing the modal
is "keep editing", so it needs no button of its own. -->
<Button variant="accent" destructive unifiedSize="sm" {loading} on:click={loadLatestDeploy}>
Use latest
</Button>
</div>
</div>
@@ -11,7 +11,7 @@
import { createEventDispatcher } from 'svelte'
import Button from '../button/Button.svelte'
import Row from './Row.svelte'
import type { RowSelection } from './rowSelection'
import { selectMenuItems, type RowSelection } from './rowSelection'
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import Badge from '../badge/Badge.svelte'
import {
@@ -188,6 +188,21 @@
const canEdit = canWrite && showEditButton
if (draft_only) {
return [
...selectMenuItems(rowSelection),
{
displayName: 'Move/Rename',
icon: FolderOpen,
action: () => {
// Addressed by the generated path its draft row sits at, but
// named by the path typed in the editor.
moveDrawer.openDrawer((app as any).draft_path ?? path, summary, 'app', {
storagePath: path,
rawApp: !!app.raw_app
})
},
disabled: !showEditButton,
hide: $userStore?.operator
},
{
displayName: 'Delete',
icon: Trash,
@@ -221,6 +236,7 @@
]
}
return [
...selectMenuItems(rowSelection),
{
displayName: 'Duplicate/Fork',
icon: GitFork,
@@ -14,7 +14,7 @@
import Badge from '../badge/Badge.svelte'
import Button from '../button/Button.svelte'
import Row from './Row.svelte'
import type { RowSelection } from './rowSelection'
import { selectMenuItems, type RowSelection } from './rowSelection'
import { sendUserToast } from '$lib/toast'
import { copyToClipboard, isOwner } from '$lib/utils'
import { isDeployable } from '$lib/utils_deployable'
@@ -219,6 +219,20 @@
const canEdit = flow.canWrite && showEditButton
if (draft_only) {
return [
...selectMenuItems(rowSelection),
{
displayName: 'Move/Rename',
icon: FolderOpen,
action: () => {
// Addressed by the generated path its draft row sits at, but
// named by the path typed in the editor.
moveDrawer.openDrawer((flow as any).draft_path ?? path, flow.summary, 'flow', {
storagePath: path
})
},
disabled: !showEditButton,
hide: $userStore?.operator
},
{
displayName: 'Delete',
icon: Trash,
@@ -242,6 +256,7 @@
]
}
return [
...selectMenuItems(rowSelection),
{
displayName: 'View runs',
icon: List,
@@ -23,7 +23,7 @@
import Badge from '../badge/Badge.svelte'
import Button from '../button/Button.svelte'
import Row from './Row.svelte'
import type { RowSelection } from './rowSelection'
import { selectMenuItems, type RowSelection } from './rowSelection'
import { sendUserToast } from '$lib/toast'
import { capitalize, copyToClipboard, isOwner } from '$lib/utils'
import { isDeployable } from '$lib/utils_deployable'
@@ -281,6 +281,7 @@
const canEdit = script.canWrite && showEditButton
if (script.draft_only) {
return [
...selectMenuItems(rowSelection),
{
displayName: 'View code',
icon: Code,
@@ -288,6 +289,19 @@
showCode(script.path, script.summary)
}
},
{
displayName: 'Move/Rename',
icon: FolderOpen,
action: () => {
// Addressed by the generated path its draft row sits at, but
// named by the path typed in the editor.
moveDrawer.openDrawer(script.draft_path ?? script.path, script.summary, 'script', {
storagePath: script.path
})
},
disabled: !showEditButton,
hide: $userStore?.operator
},
{
displayName: 'Delete',
icon: Trash,
@@ -311,6 +325,7 @@
]
}
return [
...selectMenuItems(rowSelection),
{
displayName: 'View code',
icon: Code,
@@ -1,3 +1,6 @@
import { SquareCheckBig } from 'lucide-svelte'
import type { Item } from '$lib/utils'
/**
* Wiring for a row whose kind icon doubles as a selection control: the icon
* swaps to a checkbox on hover, and stays one while a selection is active.
@@ -14,3 +17,19 @@ export type RowSelection = {
active: boolean
onToggle: (e: MouseEvent | KeyboardEvent) => void
}
/**
* The row menu's way into a selection, for the rows that offer one. The icon
* checkbox is the fast path but only appears on hover; this is the one a user
* can find by looking.
*/
export function selectMenuItems(rowSelection: RowSelection | undefined): Item[] {
if (!rowSelection) return []
return [
{
displayName: rowSelection.selected ? 'Deselect' : 'Select',
icon: SquareCheckBig,
action: (e) => rowSelection.onToggle(e)
}
]
}
+58 -2
View File
@@ -1,9 +1,53 @@
import type { Value } from '$lib/utils'
export type DiffVersionOption = {
/** Opaque to the drawer — a script hash, a flow version id, an app version id. */
id: string
/** Identifies the version: its number and hash. Carries the weight in the list. */
label: string
/** Who deployed it, and when rendered under the label in secondary text, so the
* version reads first and the attribution second. */
subtitle?: string
/** The version currently deployed. */
isHead?: boolean
}
/** How many deployed versions the diff picker asks for at a time. The history endpoints
* still answer whole when nobody asks the panels and the CLI read them that way so
* this is the picker's own appetite, not their default. */
export const VERSION_PAGE_SIZE = 20
export type DiffDrawerDiff =
| {
mode: 'normal'
deployed: Value
/** Names the deployed side, e.g. `Deployed d2154d55 by bob`. The version
* identity is stripped from the displayed metadata, so without this the
* reader cannot tell what their draft is being compared against. */
deployedLabel?: string
/** Deployed versions the reader can compare against, newest first. The
* drawer only renders the picker; each editor supplies its own list and
* fetcher because scripts, flows and apps identify versions differently. */
versions?: DiffVersionOption[]
/** Loads one version's payload. Returning `undefined` leaves the current
* comparison in place rather than blanking the diff. */
loadVersion?: (id: string) => Promise<Value | undefined>
/** Fetches the next, older page of `versions`. `versions` holds one page so the
* drawer opens without waiting on a path a pipeline has deployed thousands of
* times; the reader asks for the rest. Returning nothing ends the list. */
loadMoreVersions?: () => Promise<DiffVersionOption[] | undefined>
/** Moves the draft's base to the head and keeps its content, rendered as a
* header action so the user takes the latest with the diff in front of them.
* Called with the version the drawer is showing as head (from `versions`), so
* the base adopted is the one the reader just looked at. */
onTakeLatest?: (head?: string) => void | Promise<void>
/** The version the draft forked from. The drawer offers `onTakeLatest` only
* while it differs from the head, and passes that head to it. */
draftBase?: string
/** The version the deployed payload passed here came from, used as the head when
* no `versions` list loaded. Without either, the drawer cannot tell whether the
* draft is behind and offers nothing. */
deployedHead?: string
draft?: Value | undefined
current: Value
defaultDiffType?: 'deployed' | 'draft'
@@ -18,7 +62,19 @@ export type DiffDrawerDiff =
}
export interface DiffDrawerI {
openDrawer: () => void
/** Pass the token from `beginOpening` to continue that opening; called without one,
* the drawer claims a fresh opening, so any reuse invalidates a fetch still in
* flight rather than being overwritten by it. */
openDrawer: (token?: number) => void
closeDrawer: () => void
setDiff: (diff: DiffDrawerDiff) => void
/** Takes the opening's token on the same terms as `openDrawer`. */
setDiff: (diff: DiffDrawerDiff, token?: number) => void
/** Claim the drawer for one opening. Filling it takes awaited fetches, and a path
* change remounts the editor while this drawer stays, so the token lives here: an
* editor checks `ownsOpening` before every write and drops the opening it started
* when it goes away. */
beginOpening: () => number
ownsOpening: (token: number) => boolean
/** Drop that opening and, when it still owns the drawer, what it put on screen. */
abandonOpening: (token: number) => void
}
+12 -3
View File
@@ -10,6 +10,14 @@ import type { WorkspaceItem } from './workspacePicker'
export type FlowBuilderProps = {
initialPath?: string
/** The draft row's own path (the URL path). `initialPath` is re-seeded from the
* draft's `draft_path` so the topbar shows the pending name, so it can't be used
* to resolve what is actually deployed. */
userDraftPath?: string
/** Moves the draft's base to the head and keeps its content; offered in the diff
* drawer while the draft is behind, and called with the version the drawer shows
* as head. */
onTakeLatest?: (head?: string) => void | Promise<void>
pathStoreInit?: string | undefined
newFlow: boolean
selectedId: string | undefined
@@ -24,9 +32,10 @@ export type FlowBuilderProps = {
disabledFlowInputs?: boolean
savedPrimarySchedule?: ScheduleTrigger | undefined // used to set the primary schedule in the legacy primaryScheduleStore
version?: number | undefined
/** flow_version the draft was forked from; when set, the deploy-time staleness
* check compares it (not the load-time head `version`) against the latest. */
draftBaseVersion?: number | undefined
/** flow_version the draft was forked from (as text, like `draft_base`); when
* set, the deploy-time staleness check compares it (not the load-time head
* `version`) against the latest. */
draftBaseVersion?: string | undefined
draftTriggersFromUrl?: Trigger[] | undefined
selectedTriggerIndexFromUrl?: number | undefined
children?: import('svelte').Snippet
@@ -10,13 +10,16 @@
import Select from '$lib/components/select/Select.svelte'
import Label from '$lib/components/Label.svelte'
import { sendUserToast } from '$lib/toast'
import { useReducedMotion } from '$lib/svelte5Utils.svelte'
import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import { Archive, ArchiveRestore, FolderInput, MoreHorizontal, Trash, X } from 'lucide-svelte'
import { fly } from 'svelte/transition'
import {
blockedReason,
eligible,
movedPath,
runBulk,
sourcePath,
type BulkAction,
type BulkContext,
type BulkOutcome
@@ -36,7 +39,17 @@
let { selection, workspace, isAdmin, moveTargets, onDone }: Props = $props()
let ctx: BulkContext = $derived({ workspace, isAdmin })
let items = $derived(selection.items)
// The bar is still on screen while it flies out, and unticking the last row is
// what starts that — by then the live selection is empty. Rendering the last
// non-empty one keeps the count and the action states from blinking on the way
// out. `keepOnly` only ever removes keys, so acting on the held copy is inert.
let lastNonEmpty = $state<BulkItem[]>([])
$effect(() => {
if (selection.items.length > 0) lastNonEmpty = selection.items
})
let items = $derived(selection.items.length > 0 ? selection.items : lastNonEmpty)
let reducedMotion = useReducedMotion()
const plural = (n: number) => (n === 1 ? '' : 's')
const ACTION_LABEL: Record<BulkAction, string> = {
@@ -71,9 +84,6 @@
function actionTitle(action: BulkAction): string {
const n = targets(action).length
// Selection mode is entered from the toolbar with nothing picked yet, so this
// is the state the primary entry point lands on — it has no blocked reason.
if (items.length === 0) return `Select items to ${ACTION_LABEL[action].toLowerCase()}`
if (n === 0) return `Cannot ${ACTION_LABEL[action].toLowerCase()}: ${blockedSummary(action)}`
if (n < items.length) return `${ACTION_LABEL[action]} ${n} of the ${items.length} selected`
return `${ACTION_LABEL[action]} ${n} item${plural(n)}`
@@ -98,6 +108,11 @@
let discardReverts = $derived(pendingItems.filter((i) => !i.draftOnly))
function open(action: BulkAction) {
// Gate on the LIVE selection, not `items`. That holds the last non-empty
// copy so the bar doesn't blink to zero while it flies out, and the bar stays
// clickable for the whole 200ms outro — so without this a click landing in
// that window would open a dialog acting on rows the user just deselected.
if (selection.items.length === 0) return
if (targets(action).length === 0) return
pending = action
outcomes = undefined
@@ -156,92 +171,99 @@
}
</script>
<div
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-2 rounded-md border bg-surface px-3 py-2 shadow-lg"
>
<span class="text-xs font-semibold text-emphasis whitespace-nowrap">
{selection.size} selected
</span>
<div class="h-4 border-l"></div>
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: FolderInput }}
disabled={targets('move').length === 0}
title={actionTitle('move')}
on:click={() => open('move')}
<!-- Centred by the wrapper rather than by a translate on the bar itself: `fly`
composes its slide onto whatever transform the element already has, and a
captured `-translate-x-1/2` would be stale the moment the bar's width
changed mid-animation. -->
<div class="fixed bottom-6 inset-x-0 z-40 flex justify-center pointer-events-none">
<div
transition:fly={{ y: 32, duration: reducedMotion.val ? 0 : 200 }}
class="pointer-events-auto flex items-center gap-2 rounded-md border bg-surface px-3 py-2 shadow-lg"
>
Move{countSuffix('move')}
</Button>
<!-- Both render when both have targets: a selection mixing archived and active
rows would otherwise be a dead end for whichever action lost the toss. With
no targets either way, Archive stands alone and disabled, explaining why. -->
{#if targets('archive').length > 0 || targets('unarchive').length === 0}
<span class="text-xs font-semibold text-emphasis whitespace-nowrap">
{items.length} selected
</span>
<div class="h-4 border-l"></div>
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: Archive }}
disabled={targets('archive').length === 0}
title={actionTitle('archive')}
on:click={() => open('archive')}
startIcon={{ icon: FolderInput }}
disabled={targets('move').length === 0}
title={actionTitle('move')}
on:click={() => open('move')}
>
Archive{countSuffix('archive')}
Move{countSuffix('move')}
</Button>
{/if}
{#if targets('unarchive').length > 0}
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: ArchiveRestore }}
title={actionTitle('unarchive')}
on:click={() => open('unarchive')}
>
Unarchive{countSuffix('unarchive')}
</Button>
{/if}
<DropdownV2
placement="top-end"
items={[
{
displayName: `Discard drafts${countSuffix('discard')}`,
icon: Trash,
action: () => open('discard'),
disabled: targets('discard').length === 0,
// Only while blocked: a disabled entry can't open its modal, so the reason
// has to live here — and an enabled one would render a pointless ⓘ.
tooltip: targets('discard').length === 0 ? actionTitle('discard') : undefined
},
{
displayName: `Delete${countSuffix('delete')}`,
icon: Trash,
type: 'delete' as const,
action: () => open('delete'),
disabled: targets('delete').length === 0,
tooltip: targets('delete').length === 0 ? actionTitle('delete') : undefined
}
]}
>
{#snippet buttonReplacement()}
<!-- Both render when both have targets: a selection mixing archived and active
rows would otherwise be a dead end for whichever action lost the toss. With
no targets either way, Archive stands alone and disabled, explaining why. -->
{#if targets('archive').length > 0 || targets('unarchive').length === 0}
<Button
nonCaptureEvent
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: MoreHorizontal }}
title="More actions"
startIcon={{ icon: Archive }}
disabled={targets('archive').length === 0}
title={actionTitle('archive')}
on:click={() => open('archive')}
>
More
Archive{countSuffix('archive')}
</Button>
{/snippet}
</DropdownV2>
<div class="h-4 border-l"></div>
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: X }}
title="Cancel selection (Esc)"
on:click={() => selection.exit()}
/>
{/if}
{#if targets('unarchive').length > 0}
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: ArchiveRestore }}
title={actionTitle('unarchive')}
on:click={() => open('unarchive')}
>
Unarchive{countSuffix('unarchive')}
</Button>
{/if}
<DropdownV2
placement="top-end"
items={[
{
displayName: `Discard drafts${countSuffix('discard')}`,
icon: Trash,
action: () => open('discard'),
disabled: targets('discard').length === 0,
// Only while blocked: a disabled entry can't open its modal, so the reason
// has to live here — and an enabled one would render a pointless ⓘ.
tooltip: targets('discard').length === 0 ? actionTitle('discard') : undefined
},
{
displayName: `Delete${countSuffix('delete')}`,
icon: Trash,
type: 'delete' as const,
action: () => open('delete'),
disabled: targets('delete').length === 0,
tooltip: targets('delete').length === 0 ? actionTitle('delete') : undefined
}
]}
>
{#snippet buttonReplacement()}
<Button
nonCaptureEvent
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: MoreHorizontal }}
title="More actions"
>
More
</Button>
{/snippet}
</DropdownV2>
<div class="h-4 border-l"></div>
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: X }}
title="Cancel selection (Esc)"
on:click={() => selection.exit()}
/>
</div>
</div>
<ConfirmationModal
@@ -266,7 +288,7 @@
{@const target = moveTarget}
{@render pathList(
'Will be moved to',
pendingItems.map((i) => `${i.path} → ${movedPath(i, target)}`)
pendingItems.map((i) => `${sourcePath(i)} → ${movedPath(i, target)}`)
)}
{/if}
{:else if pending === 'discard'}
@@ -20,7 +20,6 @@
import type uFuzzy from '@leeoniya/ufuzzy'
import {
ArrowDownUp,
CheckSquare,
ChevronsDownUp,
ChevronsUpDown,
Code2,
@@ -1805,20 +1804,10 @@
{/if}
{#if !loading && !contentActive && !workspaceEmpty}
<!-- List controls, between the kind toggle and the searchbar: select mode, tree
view, expand/collapse (tree only), sort. Nothing to select, group or order on
an empty workspace, so the whole row goes. -->
<!-- List controls, between the kind toggle and the searchbar: tree view,
expand/collapse (tree only), sort. Nothing to group or order on an empty
workspace, so the whole row goes. -->
<div class="flex items-center gap-2">
{#if homeSelection.available && !homeSelection.active}
<Button
startIcon={{ icon: CheckSquare }}
iconOnly
unifiedSize="xs"
variant="default"
title="Select items — move, archive, delete or discard several at once"
on:click={() => homeSelection.enter()}
/>
{/if}
<Toggle size="xs" bind:checked={treeView} options={{ right: 'Tree view' }} />
{#if treeView}
<Button
@@ -56,10 +56,12 @@ describe('blockedReason', () => {
expect(blockedReason('unarchive', item({ archived: false }), admin)).toBeDefined()
})
it('routes a draft-only row to discard, never to move/archive/delete', () => {
it('lets a draft-only row move or discard, never archive/delete', () => {
const draftOnly = item({ draftOnly: true, isDraft: true })
expect(blockedReason('discard', draftOnly, admin)).toBeUndefined()
expect(blockedReason('move', draftOnly, admin)).toBeDefined()
// Moving one rewrites its own draft row (DraftService.moveDraft) — there is
// no deployed path, but there is somewhere for it to go.
expect(blockedReason('move', draftOnly, admin)).toBeUndefined()
expect(blockedReason('archive', draftOnly, admin)).toBeDefined()
expect(blockedReason('delete', draftOnly, admin)).toBeDefined()
})
@@ -75,4 +77,13 @@ describe('movedPath', () => {
expect(movedPath(item({ path: 'f/alpha/sub/x' }), 'f/beta')).toBe('f/beta/sub/x')
expect(movedPath(item({ path: 'u/ana/x' }), 'f/beta')).toBe('f/beta/x')
})
it('names a draft-only row by what it displays, not the path it is parked at', () => {
const parked = item({
draftOnly: true,
path: 'u/ana/draft_9f3c',
displayPath: 'u/ana/my_script'
})
expect(movedPath(parked, 'f/beta')).toBe('f/beta/my_script')
})
})
@@ -8,11 +8,21 @@
* addresses the deployed row. A draft-only item is therefore not deletable
* there is nothing deployed at its path.
*/
import { AppService, FlowService, ScriptService } from '$lib/gen'
import { AppService, DraftService, FlowService, ScriptService } from '$lib/gen'
import type { UserDraftItemKind } from '$lib/gen'
import { updateItemPathAndSummary } from '$lib/components/moveRenameManager'
import { discardDraft } from '$lib/utils_draft_deploy'
import type { BulkItem } from './homeSelection.svelte'
/** The draft overlay is the one place a raw app is its own kind. Narrowed to the
* four kinds `moveDraft` accepts a `BulkItem` is never anything else, and
* saying so lets the compiler check that rather than trusting it. */
function draftKind(
item: BulkItem
): Extract<UserDraftItemKind, 'script' | 'flow' | 'app' | 'raw_app'> {
return item.kind === 'app' && item.rawApp ? 'raw_app' : item.kind
}
export type BulkAction = 'move' | 'archive' | 'unarchive' | 'delete' | 'discard'
export type BulkContext = {
@@ -32,7 +42,6 @@ export function blockedReason(
const notOwner = 'you are not an owner of this path'
switch (action) {
case 'move':
if (item.draftOnly) return 'a draft-only item has no deployed path to move'
if (item.archived) return 'archived items cannot be moved'
if (!item.owner) return notOwner
if (!item.canWrite) return 'you do not have write permission on this path'
@@ -64,17 +73,35 @@ export function eligible(action: BulkAction, items: BulkItem[], ctx: BulkContext
return items.filter((i) => blockedReason(action, i, ctx) == undefined)
}
/** The path a move reads from. A draft-only item is parked at a generated
* storage path but named by what was typed in the editor, and it is that name
* the user expects to find under the target. */
export function sourcePath(item: BulkItem): string {
return item.draftOnly ? item.displayPath : item.path
}
/** Where an item lands under `target` (`f/<folder>` or `u/<user>`): everything
* below its own owner prefix is preserved, so nested paths keep their shape. */
export function movedPath(item: BulkItem, target: string): string {
const rest = item.path.split('/').slice(2).join('/')
const rest = sourcePath(item).split('/').slice(2).join('/')
return `${target}/${rest}`
}
async function moveItem(ctx: BulkContext, item: BulkItem, target: string): Promise<void> {
const newPath = movedPath(item, target)
// Re-saving a script at its current path would mint a pointless new version.
if (newPath === item.path) return
if (newPath === sourcePath(item)) return
if (item.draftOnly) {
// Nothing is deployed at this path, so there is no deploy to re-run: the
// item IS its draft row, and moving it rewrites that row.
await DraftService.moveDraft({
workspace: ctx.workspace,
kind: draftKind(item),
path: item.path,
requestBody: { new_path: newPath }
})
return
}
await updateItemPathAndSummary({
workspace: ctx.workspace,
kind: item.kind,
@@ -117,10 +144,15 @@ async function deleteItem(ctx: BulkContext, item: BulkItem): Promise<void> {
}
async function discardItemDraft(ctx: BulkContext, item: BulkItem): Promise<void> {
// The draft overlay is the one place a raw app is its own kind.
const kind = item.kind === 'app' && item.rawApp ? 'raw_app' : item.kind
// invalidate=false: the caller refreshes the draft list once for the batch.
const res = await discardDraft(kind, item.path, ctx.workspace, item.draftOnly, false, false)
const res = await discardDraft(
draftKind(item),
item.path,
ctx.workspace,
item.draftOnly,
false,
false
)
if (!res.success) throw new Error(res.error ?? 'discard failed')
}
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { HomeSelection, type BulkItem } from './homeSelection.svelte'
// A shift-click range resolves its ends through the rendered rows, so these need a
// document: the keys come back from `[data-row-selection-key]` in visual order.
function row(key: string): BulkItem {
return {
key,
kind: 'script',
path: key.slice('script/'.length),
displayPath: key.slice('script/'.length),
summary: '',
canWrite: true,
owner: true,
archived: false,
draftOnly: false,
isDraft: false,
rawApp: false
}
}
const a = row('script/f/a/one')
const b = row('script/f/a/two')
const c = row('script/f/a/three')
function rendered(...items: BulkItem[]): HomeSelection {
document.body.innerHTML = items
.map((i) => `<div data-row-selection-key="${i.key}"></div>`)
.join('')
const s = new HomeSelection()
s.available = true
for (const i of items) s.register(i)
return s
}
describe('HomeSelection shift-range', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
it('selects the span between the anchor and the clicked row', () => {
const s = rendered(a, b, c)
s.toggle(a)
s.toggle(c, true)
expect(s.items.map((i) => i.key)).toEqual([a.key, b.key, c.key])
})
it('does not reach back into a selection the user already emptied', () => {
const s = rendered(a, b, c)
s.toggle(a)
s.toggle(a)
s.toggle(c, true)
expect(s.items.map((i) => i.key)).toEqual([c.key])
})
})
@@ -77,21 +77,16 @@ export class HomeSelection {
/** The page offers multi-selection at all (never to an operator, and not on
* the embedded read-only variants of the list). */
available = $state(false)
/** Selection mode is on even with nothing selected yet entered from the
* toolbar, so every row reveals its checkbox before the first pick. */
private explicit = $state(false)
private selected = new SvelteMap<string, BulkItem>()
/** Every rendered selectable row, so a shift-click range can resolve the keys
* between the anchor and the clicked row back to items. */
private registry = new SvelteMap<string, BulkItem>()
private anchor: string | undefined = undefined
/** Selection mode lasts exactly as long as something is selected: unticking
* the last row drops back to the normal list. */
get active(): boolean {
return this.available && (this.explicit || this.selected.size > 0)
}
get size(): number {
return this.selected.size
return this.available && this.selected.size > 0
}
get items(): BulkItem[] {
@@ -133,12 +128,7 @@ export class HomeSelection {
this.registry.delete(key)
}
enter(): void {
this.explicit = true
}
exit(): void {
this.explicit = false
this.selected.clear()
this.anchor = undefined
}
@@ -155,13 +145,15 @@ export class HomeSelection {
}
toggle(item: BulkItem, range = false): void {
this.explicit = true
if (range && this.anchor != undefined && this.anchor !== item.key) {
if (this.selectRange(this.anchor, item.key)) return
}
if (this.selected.has(item.key)) this.selected.delete(item.key)
else this.selected.set(item.key, item)
this.anchor = item.key
// Unticking the last row ends selection mode, so the row it was on is not an
// anchor any more: the next shift-click would otherwise reach back and select
// everything between it and the row the user just started from.
this.anchor = this.selected.size === 0 ? undefined : item.key
}
/** Visual order is read back from the DOM: the tree nests rows and pages them
@@ -21,6 +21,11 @@ export async function checkFlowOnBehalfOf(
*
* Note: on_behalf_of_email is intentionally omitted from flow updates for security
* reasons the backend will redeploy the flow on behalf of the current user.
*
* `skip_draft_deletion` on every call: this re-deploys the DEPLOYED content at a
* new path, so the caller's draft is unrelated work, not the thing being
* deployed. Without the flag the backend would delete it. The backend carries
* every remaining draft at the old path over to the new one.
*/
export async function updateItemPathAndSummary(opts: {
workspace: string
@@ -47,7 +52,8 @@ export async function updateItemPathAndSummary(opts: {
dedicated_worker: flow.dedicated_worker,
ws_error_handler_muted: flow.ws_error_handler_muted,
visible_to_runner_only: flow.visible_to_runner_only,
labels
labels,
skip_draft_deletion: true
}
})
} else if (kind === 'script') {
@@ -61,7 +67,8 @@ export async function updateItemPathAndSummary(opts: {
lock: script.lock,
parent_hash: script.hash,
path: newPath,
labels
labels,
skip_draft_deletion: true
}
})
} else if (kind === 'app') {
@@ -71,7 +78,8 @@ export async function updateItemPathAndSummary(opts: {
requestBody: {
path: newPath !== initialPath ? newPath : undefined,
summary: newSummary,
labels
labels,
skip_draft_deletion: true
}
})
}
@@ -103,8 +103,16 @@
| undefined
diffDrawer?: DiffDrawer | undefined
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
/** Fired after a successful deploy; the session preview reloads on it. */
onDeploy?: (e: { path: string }) => void
/** Fired after a successful deploy; the session preview reloads on it and the route
* re-pins the draft's fork base. `version` is what this deploy wrote and `head`
* what is deployed now: the two differ when another deploy landed beside it. */
onDeploy?: (e: {
path: string
version?: number
head?: number
headBy?: string
headAt?: string
}) => void
/** Initial collapsed state for the file/runnable sidebar. The user's
* toggled preference is persisted under `sidebarStorageKey`; this prop
* only seeds the very first open. */
@@ -132,6 +140,16 @@
pendingDraftPath?: string | undefined
// Threaded to the AutosaveIndicator's "Reset to deployed" button.
onResetToDeployed?: () => void | Promise<void>
/** The app_version the draft forked from, for the deploy-time "new version
* deployed" guard: deploying is refused with a confirmation while it is not
* the head. The head at load when the draft's base is unknown; undefined for
* a draft-only app. */
version?: number | undefined
/** Moves the draft's base to the deployed head and keeps its content;
* offered in the diff drawer while the draft is behind. */
onTakeLatest?: (head?: string) => void | Promise<void>
/** The app_version the draft forked from, threaded to the topbar's diff drawer. */
draftBaseVersion?: string | undefined
// See ScriptBuilderProps — same indicator semantics.
loadedFromDraft?: boolean
othersDraftsCount?: number
@@ -203,9 +221,11 @@
onScreenshotRequester = undefined,
onRestore,
onSavedNewAppPath,
condensedHeader = false
condensedHeader = false,
version = undefined,
onTakeLatest = undefined,
draftBaseVersion = undefined
}: Props = $props()
export const version: number | undefined = undefined
// Workspace this editor operates on: the session's acting workspace when
// embedded in a session preview (autosaveWorkspace), else the navigation
@@ -249,6 +269,12 @@
// so the preview just opens the app.
setOpenInSessionHandoff({ source: () => sessionOpen })
let header: RawAppEditorHeader | undefined = $state(undefined)
/** The Deployed↔Current diff, for the route's stale-draft prompt. */
export function openDiffDrawer() {
return header?.openDiffDrawer()
}
/** Hand this app off to a fresh AI session, seeding `seedPrompt` and sending
* it on arrival. Exposed for the template picker's "Start in AI session": the
* route owns the prompt, but the draft persistence the preview depends on
@@ -2272,11 +2298,15 @@
/>
<div bind:clientWidth={rootWidth} class="max-h-full overflow-hidden h-full min-h-0 flex flex-col">
<RawAppEditorHeader
bind:this={header}
bind:jobs
bind:jobsById
bind:savedApp
bind:summary
bind:pendingDraftPath
{version}
{onTakeLatest}
{draftBaseVersion}
{onRestore}
{onSavedNewAppPath}
{policy}
@@ -28,7 +28,7 @@
Undo,
WandSparkles
} from 'lucide-svelte'
import { untrack } from 'svelte'
import { onDestroy, untrack } from 'svelte'
import { orderedJsonStringify, type Value, replaceFalseWithUndefined } from '../../utils'
import { random_adj } from '$lib/components/random_positive_adjetive'
@@ -39,6 +39,7 @@
import DeploymentHistory from '../apps/editor/DeploymentHistory.svelte'
import Awareness from '$lib/components/Awareness.svelte'
import type DiffDrawer from '$lib/components/DiffDrawer.svelte'
import { VERSION_PAGE_SIZE } from '$lib/components/diff_drawer'
import EditorHeader from '$lib/components/EditorHeader.svelte'
import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte'
@@ -109,6 +110,12 @@
}
| undefined
version?: number | undefined
/** Moves the draft's base to the deployed head and keeps its content;
* offered in the diff drawer while the draft is behind. */
onTakeLatest?: (head?: string) => void | Promise<void>
/** The app_version the draft forked from; the drawer offers `onTakeLatest` only
* while it differs from the head on display. */
draftBaseVersion?: string | undefined
newApp: boolean
newPath?: string
/** Initial labels for the app, threaded from the loaded app data. */
@@ -146,8 +153,17 @@
* falls back to `$workspaceStore`/`liveEditorDraftStoragePath`. */
autosaveWorkspace?: string
autosavePath?: string
// Fired after a successful deploy; lets the session preview reload.
onDeploy?: (e: { path: string }) => void
// Fired after a successful deploy; lets the session preview reload. `version` is
// what this deploy wrote, for the next draft's fork base; `head` is what is
// deployed now, with its author and time, and the two differ when another deploy
// landed beside this one.
onDeploy?: (e: {
path: string
version?: number
head?: number
headBy?: string
headAt?: string
}) => void
/** Surfaces the user-typed path (`newEditedPath`) up to the route
* when (and only when) it differs from the deployed/seeded
* `savedApp.path`. The route writes it into the autosaved raw-app
@@ -178,6 +194,8 @@
diffDrawer = undefined,
savedApp = $bindable(undefined),
version = $bindable(undefined),
onTakeLatest = undefined,
draftBaseVersion = undefined,
newApp,
newPath = '',
labels: initialLabels = undefined,
@@ -388,34 +406,139 @@
}
}
async function syncWithDeployed() {
async function syncWithDeployed(opening?: number) {
const deployedApp = await AppService.getAppByPath({
workspace: opWorkspace!,
path: appPath!,
withStarredInfo: true
})
// A superseded opening must not write these: the current one would then render
// and offer Take latest against the older head.
if (opening != null && !diffDrawer?.ownsOpening(opening)) return
deployedBy = deployedApp.created_by
const shownVersions = (deployedApp as { versions?: number[] }).versions
deployedVersionShown = Array.isArray(shownVersions)
? shownVersions[shownVersions.length - 1]
: undefined
// Normalize away post-deploy noise (see stripRawAppDiffNoise) so the
// diff/comparison only reflects what the editor actually changed.
deployedValue = replaceFalseWithUndefined(stripRawAppDiffNoise(deployedApp))
}
async function openDiffDrawer() {
/** The app_version the payload in `deployedValue` came from, so the picker marks that
* one as head rather than trusting the history's first row. */
let deployedVersionShown: number | undefined = $state(undefined)
/** Deployed versions for the diff picker, newest first. Best-effort: losing the
* list costs the picker, not the diff. */
/** Throws: the drawer says so and lets the reader ask for the same page again. */
async function fetchVersionPage(page: number) {
if (!opWorkspace || !appPath) return undefined
{
const history = await AppService.getAppHistoryByPath({
workspace: opWorkspace,
path: appPath,
page,
perPage: VERSION_PAGE_SIZE
})
// Head is the version the payload beside this list came from; see FlowBuilder.
const head = deployedVersionShown ?? history[0]?.version
// No ordinal: the list arrives a page at a time, so a number counted within one
// would rename versions as more load.
return history.map((h) => {
const detail = [
h.created_by,
h.created_at ? new Date(h.created_at).toLocaleString() : undefined,
h.deployment_msg
].filter(Boolean)
const isHead = h.version === head
return {
id: String(h.version),
label: `${h.version}${isHead ? ' · latest' : ''}`,
subtitle: detail.length ? detail.join(' · ') : undefined,
isHead
}
})
}
}
/** Names the deployed side when the picker has no list to label it with — both are
* read in `syncWithDeployed`, which succeeds independently of the history. */
function deployedVersionLabel(): string | undefined {
if (deployedVersionShown == null) return undefined
return `Deployed ${deployedVersionShown}${deployedBy ? ` by ${deployedBy}` : ''} · latest`
}
/** The first page, best-effort: losing it costs the picker, not the diff. */
async function deployedVersionOptions() {
try {
return await fetchVersionPage(1)
} catch {
return undefined
}
}
/** Hands the drawer the next page each time the reader asks for one. The page number
* belongs to this item's history, so it lives here — and only moves once a page has
* actually arrived, or a failed request would skip it. */
function moreVersionsLoader() {
let loaded = 1
return async () => {
const page = await fetchVersionPage(loaded + 1)
loaded += 1
return page
}
}
/** The opening this editor claimed last. A path change remounts this editor while the
* drawer stays mounted, so its teardown hands that opening back rather than leaving
* the drawer on the item the user left. */
let lastOpening: number | undefined = undefined
onDestroy(() => {
if (lastOpening != null) diffDrawer?.abandonOpening(lastOpening)
})
export async function openDiffDrawer() {
if (!savedApp) {
return
}
// The fetches below are awaited, so a reopen (or a path change, which remounts
// this editor but not the drawer) while they run must not have the older one
// land last. The drawer counts the openings for that reason.
const opening = diffDrawer?.beginOpening()
lastOpening = opening
if (opening == null) return
// deployedValue should be syncronized when we open Diff
await syncWithDeployed()
await syncWithDeployed(opening)
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? stripRawAppDiffNoise(savedApp),
current: currentDiffValue
})
// Blanking the drawer belongs to the opening that will fill it.
if (!diffDrawer?.ownsOpening(opening)) return
diffDrawer.openDrawer(opening)
const versions = await deployedVersionOptions()
if (!diffDrawer?.ownsOpening(opening)) return
diffDrawer.setDiff(
{
mode: 'normal',
deployed: deployedValue ?? stripRawAppDiffNoise(savedApp),
deployedLabel: deployedVersionLabel(),
versions,
loadMoreVersions: moreVersionsLoader(),
onTakeLatest,
draftBase: draftBaseVersion,
deployedHead: deployedVersionShown != null ? String(deployedVersionShown) : undefined,
loadVersion: async (id) => {
const v = await AppService.getAppByVersion({ workspace: opWorkspace!, id: Number(id) })
// Same normalization as `syncWithDeployed`, so switching versions doesn't
// reintroduce the post-deploy noise the head side already strips.
return replaceFalseWithUndefined(stripRawAppDiffNoise(v as any))
},
current: currentDiffValue
},
opening
)
}
async function updateApp(npath: string) {
@@ -428,7 +551,7 @@
if (!policy.execution_mode) {
policy.execution_mode = 'publisher'
}
await AppService.updateAppRaw({
const deployed = await AppService.updateAppRaw({
workspace: opWorkspace!,
path: appPath!,
formData: {
@@ -458,11 +581,14 @@
custom_path: customPath,
labels: $state.snapshot(labels)
}
const appHistory = await AppService.getAppHistoryByPath({
const head = await AppService.getAppLatestVersion({
workspace: opWorkspace!,
path: npath
})
version = appHistory[0]?.version
// The deploy's own answer is the next draft's base; `version` is what is deployed
// now, and the two differ when another deploy landed beside this one. The route
// owns this prop and re-pushes `parentVersion ?? head` as soon as `onDeploy` returns.
version = head?.version
closeSaveDrawer()
sendUserToast('App deployed successfully')
@@ -477,7 +603,13 @@
if (appPath !== npath) {
onSavedNewAppPath?.(npath)
}
onDeploy?.({ path: npath })
onDeploy?.({
path: npath,
version: deployed.version,
head: version,
headBy: head?.created_by,
headAt: head?.created_at
})
}
async function setPublishState(message?: string) {
@@ -502,6 +634,9 @@
}
let onLatest = $state(true)
/** The last comparison could not read the head, so the confirmation it raises is
* caution and not an observed deploy. Cleared by the next reading comparison. */
let headUnknown = $state(false)
async function compareVersions() {
if (version === undefined) {
return
@@ -512,9 +647,14 @@
path: appPath
})
onLatest = appVersion?.version === undefined || version === appVersion?.version
headUnknown = false
} catch (e) {
console.error('Error comparing versions', e)
onLatest = true
// The head is what this compares against, so an unanswered read is not
// evidence of being current: confirm, as an unclaimable deploy does, and say
// that is why rather than claiming a version that was never seen.
onLatest = false
headUnknown = true
}
}
@@ -607,6 +747,8 @@
{confirmCallback}
bind:open
{diffDrawer}
claimOpening={() => (lastOpening = diffDrawer?.beginOpening())}
{headUnknown}
bind:deployedValue
currentValue={currentDiffValue}
/>
@@ -622,26 +764,34 @@
if (!savedApp || newApp) {
return
}
// The other entry point into the same drawer, so it takes an opening too.
const opening = diffDrawer?.beginOpening()
lastOpening = opening
if (opening == null) return
// deployedValue should be syncronized when we open Diff
await syncWithDeployed()
await syncWithDeployed(opening)
if (!diffDrawer?.ownsOpening(opening)) return
saveDrawerOpen = false
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? stripRawAppDiffNoise(savedApp),
current: currentDiffValue,
button: {
text: 'Looks good, deploy',
onClick: () => {
if (newApp || appPath == '') {
createApp(newEditedPath)
} else {
handleUpdateApp(newEditedPath)
diffDrawer.openDrawer(opening)
diffDrawer.setDiff(
{
mode: 'normal',
deployed: deployedValue ?? stripRawAppDiffNoise(savedApp),
current: currentDiffValue,
button: {
text: 'Looks good, deploy',
onClick: () => {
if (newApp || appPath == '') {
createApp(newEditedPath)
} else {
handleUpdateApp(newEditedPath)
}
}
}
}
})
},
opening
)
}}
>
<div class="flex flex-row gap-2 items-center">
@@ -674,6 +824,7 @@
{setPublishState}
{appPath}
{onLatest}
{headUnknown}
{savedApp}
rawApp
operatingWorkspace={opWorkspace}
@@ -36,6 +36,13 @@ export interface ScriptBuilderProps {
* stop/restart pair is a no-op on a non-live entry.
*/
userDraftPath?: string
/** Moves the draft's base to the head and keeps its content; offered in the diff
* drawer while the draft is behind. Called with the version the drawer shows as
* head. The route owns it because the base lives in a per-kind field of the value. */
onTakeLatest?: (head?: string) => void | Promise<void>
/** The hash the draft forked from, so the drawer offers `onTakeLatest` only while
* it differs from the head on display. */
draftBaseHash?: string
/**
* Workspace + path the AutosaveIndicator watches for sync state. Default
* (undefined) falls back to `$workspaceStore` / `userDraftPath` the
@@ -104,6 +104,9 @@
cell.store), reactive state owned by the SessionRuntime class (via
flowCell), not by a component ancestor — so Svelte's ownership check
flags a false positive here. -->
<!-- draftBaseVersion / version: the base the session's draft carries and the head at
load time, so the deploy guard compares the same pair the full-page editor does;
without them every deploy from a session tab reads as up to date. -->
<!-- svelte-ignore ownership_invalid_binding -->
<FlowBuilder
flowStore={cell.store}
@@ -115,6 +118,10 @@
{selectedId}
loading={false}
bind:savedFlow={cell.saved.val}
draftBaseVersion={cell.store.val?.version_id != null
? String(cell.store.val.version_id)
: undefined}
version={cell.saved.val?.version_id}
{diffDrawer}
{onNavigate}
condensedHeader={true}
@@ -240,6 +240,9 @@
cell.saved.val), reactive state owned by the SessionRuntime class (via
rawAppCell), not by a component ancestor — so Svelte's ownership check
flags a false positive here. -->
<!-- version / draftBaseVersion: the pair the session's draft carries, so the
deploy guard and the drawer compare what the full-page editor does;
without them every deploy from a session tab reads as up to date. -->
<!-- svelte-ignore ownership_invalid_binding -->
<RawAppEditor
bind:files={cell.store.val.files}
@@ -253,6 +256,10 @@
autosavePath={path}
policy={cell.store.val.policy}
bind:savedApp={cell.saved.val}
version={cell.store.val?.parent_version ?? cell.saved.val?.deployed_version}
draftBaseVersion={cell.store.val?.parent_version != null
? String(cell.store.val.parent_version)
: undefined}
newApp={!cell.saved.val || cell.saved.val.no_deployed === true}
{diffDrawer}
{onNavigate}
@@ -78,3 +78,25 @@ describe('appDraftCodec — draft_path round-trip', () => {
expect(back.draft_path).toBe('u/admin/renamed')
})
})
describe('appDraftCodec — fork base round-trip', () => {
it('carries the draft version both ways', () => {
const draft = runtimeRawAppToDraft(runtime({ parent_version: 3 }))
expect(draft.parent_version).toBe(3)
expect(applyDraftToRuntimeRawApp(runtime({ parent_version: 1 }), draft).parent_version).toBe(3)
})
it('keeps an unknown base unknown rather than adopting the runtime version', () => {
const dv: RawAppDraft = {
summary: 'app',
files: {},
runnables: {},
data: { tables: [] } as any
}
// This result round-trips into the next save: a fallback here would claim the
// content forked from the version the session happens to hold.
expect(applyDraftToRuntimeRawApp(runtime({ parent_version: 2 }), dv).parent_version).toBe(
undefined
)
})
})
@@ -16,6 +16,9 @@ export type RawAppDraft = {
// friendly name (they read `value->>'draft_path'`) — and so editing the path
// in the editor changes the persisted draft and triggers an autosave.
draft_path?: string
// The app_version the draft forked from. The server derives `draft.base` from
// it, which is what tells a draft that is behind the deployed head.
parent_version?: number
}
// The shape a raw-app cell's store (`RawAppRuntimeValue` in
@@ -30,6 +33,7 @@ export type RuntimeRawApp = {
policy: any
custom_path?: string
draft_path?: string
parent_version?: number
}
// Strip runtime-only metadata (just `path`, the storage key) when persisting
@@ -43,7 +47,8 @@ export function runtimeRawAppToDraft(raw: RuntimeRawApp): RawAppDraft {
data: raw.data,
policy: raw.policy,
custom_path: raw.custom_path,
draft_path: raw.draft_path
draft_path: raw.draft_path,
parent_version: raw.parent_version
}
}
@@ -58,6 +63,10 @@ export function applyDraftToRuntimeRawApp(raw: RuntimeRawApp, dv: RawAppDraft):
data: dv.data,
policy: dv.policy ?? raw.policy,
custom_path: dv.custom_path ?? raw.custom_path,
draft_path: dv.draft_path ?? raw.draft_path
draft_path: dv.draft_path ?? raw.draft_path,
// The incoming draft's own fork base, absence included: this round-trips into the
// next save, so falling back to the runtime's version would give that content a
// base it never forked from and hide that it is behind.
parent_version: dv.parent_version
}
}
@@ -128,6 +128,9 @@ export interface RawAppRuntimeValue {
path: string
custom_path?: string
draft_path?: string
/** The app_version this cell's content forked from, carried so the editor's deploy
* guard and diff drawer compare the same pair the full-page editor does. */
parent_version?: number
}
// The deployed baseline a raw-app cell diffs against (topbar Diff drawer).
export interface RawAppSavedValue {
@@ -143,6 +146,9 @@ export interface RawAppSavedValue {
/** No deployed counterpart (draft-only); disables the topbar Diff. */
no_deployed?: boolean
custom_path?: string
/** The deployed head at load time, which the editor's deploy guard falls back to when
* the draft carries no base of its own. */
deployed_version?: number
}
// One editor cell per (kind, path) the session loads: the load slot plus the
@@ -587,8 +593,9 @@ function createRuntime(session: Session): SessionRuntime {
// when the path has never been deployed.
const aiDraft = UserDraft.get<Flow>('flow', path, { workspace })
// getDraft=true omits version_id (the plain getFlowByPath has it) —
// stamp it on so the flow doesn't always diff. Best-effort.
// Fallback for the head: the payload fetches below carry `version_id`, and
// this one covers a response that does not. Best-effort, and a request of
// its own, so the same response wins wherever both are available.
let deployedVersionId: number | undefined
try {
deployedVersionId = (await FlowService.getFlowByPath({ workspace, path }))?.version_id
@@ -602,12 +609,16 @@ function createRuntime(session: Session): SessionRuntime {
// yet on the backend — draft-only flows are a valid state.
try {
const result = await FlowService.getFlowByPath({ workspace, path, getDraft: true })
saved.val = result as SavedFlow
// The editor's deploy guard compares against the head, so keep this
// response's own and fall back to the one fetched above.
saved.val = {
...(result as SavedFlow),
version_id: (result as SavedFlow).version_id ?? deployedVersionId
}
} catch {
saved.val = undefined
}
await initFlow(aiDraft, store, stateStore, workspace)
if (deployedVersionId != null && store.val) store.val.version_id = deployedVersionId
slot.loadedPath = path
slot.loadedWorkspace = workspace
return
@@ -615,8 +626,12 @@ function createRuntime(session: Session): SessionRuntime {
// No local draft yet — seed from `result.draft ?? result`.
const result = await FlowService.getFlowByPath({ workspace, path, getDraft: true })
saved.val = result as SavedFlow
const flow: Flow = ((result as SavedFlow).draft ?? (result as Flow)) as Flow
saved.val = {
...(result as SavedFlow),
version_id: (result as SavedFlow).version_id ?? deployedVersionId
}
const serverDraft = (result as SavedFlow).draft as Flow | undefined
const flow: Flow = (serverDraft ?? (result as Flow)) as Flow
// Seed the per-tab last_sync from the server draft's timestamp so the
// seeding save below attaches a matching last_sync and the server can
// reject stale writes (see loadRawApp). Without this a server draft —
@@ -629,7 +644,13 @@ function createRuntime(session: Session): SessionRuntime {
)
UserDraft.save('flow', path, flow, { workspace })
await initFlow(flow, store, stateStore, workspace)
if (deployedVersionId != null && store.val) store.val.version_id = deployedVersionId
// A draft keeps the base it forked from, unknown included (it then falls
// back to the timestamps); only a fresh checkout takes the head, which is
// also what keeps it from always diffing. See loadScript. The head comes
// from the response that supplied the payload, so a deploy landing between
// the two requests cannot label this checkout as forked from the older one.
const head = (result as SavedFlow).version_id ?? deployedVersionId
if (head != null && store.val && !serverDraft) store.val.version_id = head
slot.loadedPath = path
slot.loadedWorkspace = workspace
} catch (err) {
@@ -672,11 +693,10 @@ function createRuntime(session: Session): SessionRuntime {
}
// Clone before layering the AI draft on top, else we'd mutate
// `saved.val` in place and lose the pristine diff baseline.
const savedDraft = saved.val?.draft as NewScript | undefined
const baseline: NewScript = saved.val
? (structuredClone(
$state.snapshot(
(saved.val.draft as NewScript | undefined) ?? (saved.val as NewScript)
)
$state.snapshot(savedDraft ?? (saved.val as NewScript))
) as NewScript)
: {
// Seed from the draft's own path (a rename lives in `draft_path`,
@@ -694,9 +714,8 @@ function createRuntime(session: Session): SessionRuntime {
schema: emptySchema(),
language: (aiDraft.language ?? 'bun') as any
}
if (saved.val?.hash) {
baseline.parent_hash = saved.val.hash
}
// Only a fresh checkout forks from the head; see the branch below.
if (!savedDraft && saved.val?.hash) baseline.parent_hash = saved.val.hash
baseline.content = aiDraft.content
if (aiDraft.language) baseline.language = aiDraft.language
if (aiDraft.summary !== undefined) baseline.summary = aiDraft.summary
@@ -711,10 +730,13 @@ function createRuntime(session: Session): SessionRuntime {
saved.val = result as SavedScript
// Clone before mutating, else `baseline` aliases `result` and
// `baseline.parent_hash` corrupts the diff baseline.
const baseline = structuredClone(
((result as SavedScript).draft as NewScript | undefined) ?? (result as NewScript)
)
baseline.parent_hash = result.hash
const serverDraft = (result as SavedScript).draft as NewScript | undefined
const baseline = structuredClone(serverDraft ?? (result as NewScript))
// Only a fresh checkout forks from the head. A draft keeps the base it has,
// unknown included: `draft.base` is derived from `parent_hash` on every
// save, so stamping the head over it would say this draft is up to date
// when it is not. An unknown base falls back to the timestamps.
if (!serverDraft) baseline.parent_hash = result.hash
// Seed the per-tab last_sync from the server draft's timestamp so the
// seeding save below attaches a matching last_sync and the server can
// reject stale writes (see loadRawApp). Without this a server draft —
@@ -775,7 +797,10 @@ function createRuntime(session: Session): SessionRuntime {
path: result.path,
policy: result.policy,
custom_path: result.custom_path,
no_deployed: result.no_deployed
no_deployed: result.no_deployed,
deployed_version: Array.isArray(result.versions)
? result.versions[result.versions.length - 1]
: undefined
}
} catch {
saved.val = undefined
@@ -811,7 +836,10 @@ function createRuntime(session: Session): SessionRuntime {
path: result.path,
policy: result.policy,
custom_path: result.custom_path,
no_deployed: result.no_deployed
no_deployed: result.no_deployed,
deployed_version: Array.isArray(result.versions)
? result.versions[result.versions.length - 1]
: undefined
}
// Prefer the server draft over the deployed value (mirrors the
// flow/script `result.draft ?? result`). A raw-app draft is already
@@ -841,7 +869,14 @@ function createRuntime(session: Session): SessionRuntime {
summary: draftValue?.summary ?? result.summary ?? '',
path: result.path,
custom_path: draftValue?.custom_path ?? result.custom_path,
draft_path: draftValue?.draft_path
draft_path: draftValue?.draft_path,
// Only a fresh checkout forks from the head; a draft keeps its own base,
// unknown included, or it would read as up to date. See loadScript.
parent_version: draftValue
? draftValue.parent_version
: Array.isArray(result.versions)
? result.versions[result.versions.length - 1]
: undefined
}
// Seed the per-tab last_sync from the server draft's timestamp so
// later saves attach a matching last_sync and the server can reject
@@ -214,6 +214,13 @@ const syncLocked = new Map<string, (() => void) | undefined>()
*/
const conflicts = new SvelteMap<string, DraftConflictInfo>()
/**
* Per-key listeners fired when a save lands at a path other than the key's:
* the item was moved while the editor was open, and the server put the save
* where the move took its drafts. The editor follows too (see `onRelocated`).
*/
const relocationListeners = new Map<string, Set<(newPath: string) => void>>()
/**
* Draft keys whose last save threw (network / 5xx) extracted error
* message. Cleared on the next success. Drives the AutosaveIndicator's
@@ -333,6 +340,13 @@ async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise<void> {
// cached state the same way an upsert does. Listener errors must never
// make a committed save read as failed.
notifyAnySaved({ workspace: opts.workspace, itemKind: opts.itemKind, path: opts.path })
// The item had moved and the write landed where its drafts went — a discard
// included, since the editor that sent it is on a path the item has left. Last,
// so the editor that reacts (by leaving this path) sees a settled key.
if (resp.path && resp.path !== opts.path) {
const listeners = relocationListeners.get(key)
if (listeners) for (const l of [...listeners]) l(resp.path)
}
} catch (e) {
console.error('UserDraftDbSyncer.save failed', e)
// Leave pending opts in place so the next attempt retries the same
@@ -614,6 +628,28 @@ export const UserDraftDbSyncer = {
conflicts.delete(draftKey(query.workspace, query.itemKind, query.path))
},
/**
* Subscribe to saves for a draft key that landed at another path: the item
* was moved while this editor was open. Fired with the path the save landed
* at, after the save is fully accounted for. Returns an
* unsubscribe.
*/
onRelocated(query: UserDraftLastSyncQuery, listener: (newPath: string) => void): () => void {
const key = draftKey(query.workspace, query.itemKind, query.path)
let set = relocationListeners.get(key)
if (!set) {
set = new Set()
relocationListeners.set(key, set)
}
set.add(listener)
return () => {
const s = relocationListeners.get(key)
if (!s) return
s.delete(listener)
if (s.size === 0) relocationListeners.delete(key)
}
},
/**
* Force-save: bypass the `last_sync` check and overwrite the server row
* (conflict modal's "Overwrite the remote"). Resolves once the key's save
+1
View File
@@ -1442,6 +1442,7 @@ const CLEANED_VALUE_KEYS = new Set([
'draft_only',
'draft_saved_at',
'draft_created_at',
'draft_base',
'is_draft',
'other_drafts_users',
'created_at',
+13 -17
View File
@@ -41,30 +41,26 @@ import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
// fabricates) the "started from an older deployed version" warning.
describe('draftBaseIsStale', () => {
it('script: stale iff the draft parent_hash differs from the deployed hash', () => {
expect(draftBaseIsStale('script', { hash: 'v2', draft: { parent_hash: 'v1' } })).toBe(true)
expect(draftBaseIsStale('script', { hash: 'v2', draft: { parent_hash: 'v2' } })).toBe(false)
it('script: stale iff draft_base differs from the deployed hash', () => {
expect(draftBaseIsStale('script', { hash: 'v2', draft_base: 'v1' })).toBe(true)
expect(draftBaseIsStale('script', { hash: 'v2', draft_base: 'v2' })).toBe(false)
})
it('script: no base pointer or no head → not stale (nothing to compare)', () => {
it('script: no base or no head → not stale (nothing to compare)', () => {
expect(draftBaseIsStale('script', { hash: 'v2', draft: {} })).toBe(false)
expect(draftBaseIsStale('script', { draft: { parent_hash: 'v1' } })).toBe(false)
expect(draftBaseIsStale('script', { draft_base: 'v1' })).toBe(false)
})
it('flow: compares the pinned version_id against the deployed head', () => {
expect(draftBaseIsStale('flow', { version_id: 7, draft: { version_id: 5 } })).toBe(true)
expect(draftBaseIsStale('flow', { version_id: 7, draft: { version_id: 7 } })).toBe(false)
expect(draftBaseIsStale('flow', { version_id: 7, draft: {} })).toBe(false)
it('flow: compares draft_base against the deployed version_id as text', () => {
expect(draftBaseIsStale('flow', { version_id: 7, draft_base: '5' })).toBe(true)
expect(draftBaseIsStale('flow', { version_id: 7, draft_base: '7' })).toBe(false)
expect(draftBaseIsStale('flow', { version_id: 7 })).toBe(false)
})
it('app/raw_app: compares parent_version against the last of versions', () => {
expect(draftBaseIsStale('app', { versions: [1, 2, 3], draft: { parent_version: 2 } })).toBe(
true
)
expect(draftBaseIsStale('raw_app', { versions: [1, 2, 3], draft: { parent_version: 3 } })).toBe(
false
)
expect(draftBaseIsStale('app', { versions: [], draft: { parent_version: 2 } })).toBe(false)
it('app/raw_app: compares draft_base against the last of versions', () => {
expect(draftBaseIsStale('app', { versions: [1, 2, 3], draft_base: '2' })).toBe(true)
expect(draftBaseIsStale('raw_app', { versions: [1, 2, 3], draft_base: '3' })).toBe(false)
expect(draftBaseIsStale('app', { versions: [], draft_base: '2' })).toBe(false)
})
it('no draft on the response → not stale', () => {
+19 -17
View File
@@ -262,7 +262,7 @@ export async function getDraftDiffValues(
// 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 / no_deployed /
// 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') {
@@ -271,6 +271,7 @@ export async function getDraftDiffValues(
draft,
is_draft: _i,
draft_saved_at: _c,
draft_base: _b,
no_deployed,
other_drafts_users: _o,
hash: _h,
@@ -289,6 +290,7 @@ export async function getDraftDiffValues(
draft,
is_draft: _i,
draft_saved_at: _c,
draft_base: _b,
no_deployed,
other_drafts_users: _o,
version_id: _v,
@@ -369,24 +371,24 @@ export async function getDraftDiffValues(
/**
* Whether a draft's base is stale: the deployed version the draft forked from
* no longer matches the current deployed head a newer version was deployed
* after the draft began, so deploying the draft would silently revert it.
* Scripts compare the draft's `parent_hash` vs the deployed `hash`; flows the
* pinned `version_id` vs the deployed head `version_id`; apps (incl. raw) the
* pinned `parent_version` vs the head of `versions`. `r` is the item fetched
* with `get_draft=true`; only script/flow/app kinds carry a base pointer.
* (`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 draft = r?.draft
if (!draft) return false
if (draftKind === 'script') {
return !!r.hash && !!draft.parent_hash && draft.parent_hash !== r.hash
}
if (draftKind === 'flow') {
return r.version_id != null && draft.version_id != null && draft.version_id !== r.version_id
}
const head = Array.isArray(r.versions) ? r.versions[r.versions.length - 1] : undefined
return head != null && draft.parent_version != null && draft.parent_version !== head
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
@@ -51,8 +51,9 @@
let deployedAt = $state<string | undefined>(undefined)
// The app_version the draft was forked from (pinned), + the deployed head, for
// the precise staleness check in DraftEditorModals (vs the drifting timestamp).
let draftBaseVersion = $state<number | undefined>(undefined)
let deployedHeadVersion = $state<number | undefined>(undefined)
let draftBaseVersion = $state<string | undefined>(undefined)
let deployedHeadVersion = $state<string | undefined>(undefined)
let deployedBy = $state<string | undefined>(undefined)
/** Increments per `loadApp` call. Stale loads (e.g. when picker
* navigation races a draft-discard reload) bail at the next checkpoint
@@ -90,6 +91,7 @@
// reused route and falsely trip the stale-draft modal.
draftBaseVersion = undefined
deployedHeadVersion = undefined
deployedBy = undefined
// Brand-new app: no deployed baseline, so never discard-on-equal.
deployedBaseline = undefined
const templatePath = page.url.searchParams.get('template')
@@ -274,13 +276,15 @@
// `no_deployed` — no baseline to be older than.
draftSavedAt = backendApp.draft_saved_at as string | undefined
deployedAt = backendApp.no_deployed ? undefined : (backendApp.created_at as string | undefined)
// `parent_version` rides on the persisted draft (pinned at fork); undefined
// for a pre-feature draft. Head = the last entry of the deployed `versions`.
draftBaseVersion = savedDraftApp?.parent_version
deployedHeadVersion =
deployedBy = backendApp.no_deployed ? undefined : (backendApp.created_by as string | undefined)
// The app_version the draft forked from; undefined for a draft never forked
// from a deploy. Head = the last entry of the deployed `versions`.
draftBaseVersion = backendApp.draft_base
const headVersion =
backendApp.no_deployed || !backendApp.versions
? undefined
: backendApp.versions[backendApp.versions.length - 1]
deployedHeadVersion = headVersion != null ? String(headVersion) : undefined
const backendApp_ = structuredClone(stateSnapshot(backendApp))
savedApp = {
summary: backendApp_.summary,
@@ -303,6 +307,11 @@
}
if (pendingLoad) {
backendApp = { ...backendApp, value: pendingLoad.value as App } as typeof backendApp
// Their draft's base, not ours: the prompt and the deploy guard read it, and
// deploying their content on our base would claim a version it never forked
// from. See /scripts/edit.
const theirs = (pendingLoad.value as App)?.parent_version
draftBaseVersion = theirs != null ? String(theirs) : undefined
if (hasOwnDraft) {
// AppEditor `migrateApp`s the value in place on mount (see its
// `migratedDeployedBaseline`), so the draft cell settles to the
@@ -330,7 +339,8 @@
// draft value. An existing own draft already carries it (preserved by the
// value swap above). `parent_version` is in DRAFT_COMPARE_IGNORED_FIELDS, so it
// never trips the autosave no-op / "unsaved changes" comparison.
if (!hasOwnDraft && !backendApp.no_deployed && backendApp.value) {
// Not after loading a teammate's draft either: that value carries their base.
if (!hasOwnDraft && !pendingLoad && !backendApp.no_deployed && backendApp.value) {
const versions = (backendApp as { versions?: number[] }).versions
const head = Array.isArray(versions) ? versions[versions.length - 1] : undefined
if (head != null) (backendApp.value as App).parent_version = head
@@ -444,6 +454,7 @@
{deployedAt}
{draftBaseVersion}
{deployedHeadVersion}
{deployedBy}
onLoadLatestDeploy={async () => {
if (!$workspaceStore) return
await runResetToDeployed({
@@ -484,6 +495,26 @@
{loadedFromDraft}
othersDraftsCount={otherDraftsUsers.length}
onOpenOthersDrafts={() => (othersModalOpen = true)}
onDeploy={({ version, head, headBy, headAt }) => {
// The editor stays open across a deploy and pins what it wrote onto the value
// itself. Keep that as the prompt's base while this deploy is the head, so
// the pair still describes the draft the next edit starts; a raced deploy
// leaves it unknown, or the pair would differ and open the prompt on the
// draft this deploy consumed.
draftBaseVersion = version != null && version === head ? String(version) : undefined
draftSavedAt = undefined
if (head != null) {
// Named by whoever deployed the head, not by the page load's author.
deployedHeadVersion = String(head)
deployedBy = headBy
deployedAt = headAt
}
// Another deploy landed on top of this one: there is no draft for the prompt
// to talk about, so say what happened instead.
if (version != null && head != null && version !== head) {
sendUserToast(`Version ${head} was deployed on top of yours (${version})`)
}
}}
/>
</div>
{/if}

Some files were not shown because too many files have changed in this diff Show More