mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat: Add section to deploy projects to hub (#9332)
* feat: add Deploy to Hub workspace settings tab * Init record logic * Fix wordings * Add publish-app drawer with per-app rate limit mock - Publish drawer on raw_apps/apps exposes public URL, copy-iframe, unpublish - Inline per-app rate limit config (req/min, burst, per-IP toggle) - Rename workspace settings "Default app" tab header to "Apps" to cover both default app and public rate limiting Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Simplify publish drawer to show workspace-wide rate limit only Drop per-app rate limit fields (req/min, burst, per-IP) — none of these are supported by the backend. The drawer now shows the existing workspace-level rate limit read-only with a link to edit it in Workspace settings → Apps. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Rename publish-app drawer wording to 'Share as iframe' 'Publish publicly' was ambiguous (publish to Hub vs make public URL). Use 'Share as iframe' for the button and drawer title, and 'Generate iframe' for the confirm action. Intro text now explicitly mentions iframe embedding use cases (Hub, docs page, own site). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Wire DeployToHub to real workspace data - Fetch apps, raw_apps, flows, scripts, resources via their services - Fetch workspace rate limit via WorkspaceService.getSettings - Share-as-iframe flips app policy.execution_mode to 'anonymous' via AppService.updateApp and resolves the real public URL via getPublicSecretOfApp + computeSecretUrl - Detect already-public apps from listApps execution_mode field - Filter out app_theme resources (noise, present in every workspace) - Hub bundle/version push and recording remain mocked (no backend yet) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Wire recordings to real jobs with run-preview UX - Recording flow now fetches the real schema, runs the job, and polls getCompletedJobResultMaybe to surface success/failure before saving. - Drawer shows a sticky status box (loader / success / failure) with a result preview, a job link, and an in-context Save CTA. - Only successful runs can be saved as a recording. Failures show the error and offer re-run. - Filter cache/state/app_theme internal resource types (mirrors workspaces_export.rs filter). - Added "What is a recording?" explainer banner above the items list. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add draft/review state machine and submission gating - Phases: predeploy → draft → under_review → live, with workflow step indicator and contextual footer actions per phase - Bundle drawer collects name + readme before pushing the draft - draftItems snapshot frozen at deploy time; workspaceItems keep refreshing without affecting the draft - Folder MultiSelect lets users scope the bundle to one or more folders; empty = whole workspace - Submit-for-review disabled until every script and flow in the draft has a recording (progress bar + counter) - Recordings now run the real job and poll for success/failure; only successful runs can be saved - under_review phase locks editing, sharing, and recording - Dark mode variants on every coloured banner - Steps card shows the full 3-step process always, highlighting the current step Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Make recordings optional, encourage them for discoverability - Submit for review no longer gated on full recordings - Footer hint now frames recordings as boosting approval speed and public Hub featuring, not as a hard requirement - Progress card label switched from 'Recordings needed' to 'Recordings recommended' - Items without a recording display a yellow 'No recording' badge in every phase so the gap stays visible after submission Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Allow per-item selection inside the bundle scope - Items in predeploy now have checkboxes (all selected by default) - Select all / Deselect all act on the current folder filter - manualDeselected resets when the folder filter changes - Bundle button uses the selected count, disabled when zero - Draft snapshot keeps only the selected items - Checkboxes hidden in draft / under_review / live phases Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add diff button once approved by admins * Small fix * Nits * fix(deploy-to-hub): paginate workspace list and cancel stale record polls - loadWorkspace fetches all pages instead of capping at 100 items per kind - pollJobUntilComplete now bails when recordRunSeq advances (new record target, re-run, or drawer close), preventing late completion of a previous run from overwriting current state Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf(deploy-to-hub): parallelize public-app URL resolution resolvePublicUrl now runs once per anonymous app via Promise.all instead of serially inside the items loop, removing N round-trips from initial tab load. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(indexer): tell admins when ingress routes search to wrong pod (#9274) * [ee] fix(indexer): tell admins when ingress routes search to wrong pod When the IndexReader is absent on the pod handling a search request but another pod is actively holding the indexer lock, the EE handler now returns a tailored error pointing at the ingress/load-balancer configuration instead of the generic "indexer not running" message. The indexer status endpoint reads the DB lock so it reports "running" from any pod, but search endpoints need the in-memory IndexReader that only exists on the lock holder. In multi-replica deployments this looks like the indexer is healthy but every search 404s. Companion: windmill-labs/windmill-ee-private#TBD Fixes WIN-1968. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to eb18d7b4c0e37fea3f6e1e2cc44e0fddd74ff817 This commit updates the EE repository reference after PR #586 was merged in windmill-ee-private. Previous ee-repo-ref: 7dd43d1850813071cc18ba49ba090583e7321f4b New ee-repo-ref: eb18d7b4c0e37fea3f6e1e2cc44e0fddd74ff817 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com> * feat(cli): add `wmill init prompts` and custom override slot (#9266) * feat(cli): add `wmill init prompts` and custom override slot Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cli): replace init prompts with refresh prompts + AGENTS.md/AGENTS.cli.md split Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): dedupe claude skills via @-includes and add prompts freshness check Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cli): drop migration-choice flags from `refresh prompts` Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(cli): add 'Running and previewing local changes' section to AGENTS.cli.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): write full skill content to .claude/, drop @-include wrapper Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): reconcile CLAUDE.md the same way as AGENTS.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): address PR review nits — argv parsing, lazy import, comment detection, error propagation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: add yolo mode for ai chat tools (#9258) * feat: add yolo mode for ai chat tools * nit * fix: align chat footer controls * feat: add ai chat autonomy modes * feat: add autonomy mode dropdown * fix: highlight yolo autonomy icon * fix: auto accept flow edits * fix: hide unsupported autonomy modes * fix: handle auto-accept flow editor races * fix(debugger): add non-root user support to Dockerfile (#9277) Mirrors the main Windmill Dockerfile pattern: creates a windmill user (UID/GID 1000) and makes cache/work directories world-writable so the image runs cleanly under Kubernetes securityContext.runAsNonRoot or runAsUser: 1000 without permission errors on Bun, pip, or windmill cache writes. Fixes WIN-1969 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai): enforce RLS and scope check on user-supplied X-Resource-Path (#9276) * fix(ai): enforce RLS and scope check on user-supplied X-Resource-Path The AI proxy handler accepts an X-Resource-Path header to override the configured workspace AI provider. When supplied, the handler loaded the resource value from the resource table using the root DB pool with no resources:read scope check, so any authenticated workspace user could point X-Resource-Path at a restricted AI resource (e.g. one in a folder they cannot read) and the proxy would use that resource's provider credentials for the outbound AI request. For user-supplied resource paths, now require resources:read:{path} scope and fetch the resource through user_db.begin(&authed) so RLS enforces the same folder/group boundary as the resource API. The RLS- scoped $var: resolution stays in place as defense in depth. The admin-configured workspace/instance ai_config path is unchanged. Fixes WIN-1971 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(ai): regression test for X-Resource-Path RLS enforcement Cover all four cases: - non-admin pointing X-Resource-Path at a restricted resource is rejected - non-admin pointing it at a resource they own still works - admin can point it at any resource - workspace-configured proxy flow (no X-Resource-Path) is unchanged Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: add userdraft listing primitives (#9268) * feat: add userdraft listing primitives * fix: cancel stale userdraft discard writes * docs: remove global ai userdraft plan * feat(nsjail): optional disk-backed /tmp via instance setting (#9272) * feat(nsjail): optional disk-backed /tmp via instance setting * test(nsjail): unit-test tmp mount resolver and narrow visibility * refactor(nsjail): switch tmp backing to select + conditional UI * ui(nsjail): make tmpfs the visible default in /tmp backing select * fix(nsjail): refuse preexisting jail_tmp to block symlink escape * fix(nsjail): allow jail_tmp reuse on sequential nsjail calls Codex flagged that python/ruby/rust executors invoke nsjail twice per job_dir (install then run). The previous resolver treated any preexisting jail_tmp as hostile and silently fell back to tmpfs on the second call, so disk-backed mode never reached the main script run for those langs. Use symlink_metadata().is_dir() to distinguish a real directory left by an earlier call in the same job_dir (safe to reuse) from a symlink or other entity (still refused, as the codebase-tar escape requires). Also loosen the frontend visibility predicate: only hide nsjail settings when job_isolation is explicitly 'none' or 'unshare', so deployments that enable nsjail via DISABLE_NSJAIL=false with no DB setting can still see the controls. * chore(main): release 1.706.0 (#9270) * chore(main): release 1.706.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> * fix(nsjail): gate unix-symlink test behind cfg(unix) for Windows build (#9280) The disk_backed_refuses_preexisting_symlink_at_jail_tmp test calls std::os::unix::fs::symlink directly, which doesn't exist on Windows targets. Without a cfg gate, `cargo check --tests` fails on Windows with E0433. Other symlink call sites in this crate (php_executor, bun_executor, rust_executor, etc.) already follow this pattern. Fixes WIN-1972 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Reduce slim image vulnerability surface (#9279) * Reduce slim image vulnerability surface * chore(docker): drop apt-get upgrade -y from slim images apt-get upgrade hurts build reproducibility (same Dockerfile + same commit at different times produces divergent images) and trips hadolint DL3005. The freshness it buys is dominated by simply rebuilding against the periodically-refreshed debian:bookworm-slim base image. The --no-install-recommends and apt-list cleanup wins are kept. --------- Co-authored-by: Ruben Fiszel <ruben@windmill.dev> * fix(git-sync): bump to hub/28234 with stateless gpg.program wrapper (WIN-1974) (#9282) * fix(git-sync): revert LATEST_GIT_SYNC_SCRIPT_PATH to hub/28230 to restore GPG-signed deploys (WIN-1974) hub/28231 (PR #9230) is the "thin" script that hands the actual `git commit` to the CLI's hidden `sync git-deploy`. The hub script still does the GPG setup (import key into a fresh GNUPGHOME, dummy `gpg -bsau` to warm the agent passphrase cache, then `git config user.signingkey` + `commit.gpgsign` locally), but the commit no longer runs in the same `git_push` flow — it runs minutes later inside the CLI after workspace API resolution, zip pull, file extraction, and lockfile autofill. By the time the spawned `git commit` asks gpg-agent for the cached passphrase, the cache state is no longer reliable (or the spawned `gpg` ends up talking to a fresh agent), so signing fails non-interactively with `gpg failed to sign the data`. hub/28230 is hub/28217's in-script logic rebuilt with windmill-cli@1.703.3: the GPG setup and the in-script `sh_run("git commit ...")` happen back-to-back in `git_push`, so the cache is always fresh. It preserves wm_deploy / fork branch behavior, the EE deployment-callback `main()` signature is unchanged, and the only min-version check in EE (`is_script_meets_min_version(28103)`) is comfortably below 28230 — so this revert is safe. Forward fix (separate PR): publish a new thin script that, alongside the existing GPG setup, writes a `gpg.program` wrapper using `--pinentry-mode loopback --passphrase-file` so signing is independent of the agent's cache state. Re-bump past 28231 then. Fixes WIN-1974 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(git-sync): check in source-of-truth for the next hub script (gpg.program wrapper) This is the script that will be published to hub.windmill.dev once verified on a customer GPG-signed deploy. It replaces hub/28231's agent-cache pre-warm (`gpg -bsau` with --passphrase) with a stateless gpg.program wrapper + chmod-600 passphrase file. Every git-invoked gpg call goes through the wrapper, which always uses --pinentry-mode loopback (and --passphrase-file when a passphrase exists). Signing no longer depends on gpg-agent having a cached passphrase by the time the CLI's `git commit` runs — which closes WIN-1974. Not wired in yet: LATEST_GIT_SYNC_SCRIPT_PATH stays on hub/28230 until this script is uploaded and the new hub id is known. This file is checked in so the diff is reviewable, future bumps have a source of truth, and a CLI regression test can `cat` it for fixture parity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(frontend): skip format/pattern validation for $var/$res/$jsonvar references in ArgInput A resource field with a `pattern` constraint (e.g. the gpg_key.private_key field, whose pattern enforces a `-----BEGIN PGP PRIVATE KEY BLOCK-----` prefix) rejects values like `$var:u/me/gpg-private-key` with an "invalid format" error in the resource editor — even though `$var:`/`$res:`/`$jsonvar:` are placeholders the backend resolves at runtime, not the actual string that needs to match the regex. Bail out of all format/pattern checks (email, ipv4, ipv6, uuid, custom pattern) when the value is one of these references. Required/numeric bounds/array checks still apply since they're shape-level, not regex. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(git-sync): bump LATEST_GIT_SYNC_SCRIPT_PATH to hub/28234 (gpg.program-wrapper fix) hub/28234 is the forward fix for WIN-1974: replaces hub/28231's agent-cache pre-warm (which became stale by the time the CLI's `git commit` ran) with a stateless `gpg.program` wrapper that uses `--pinentry-mode loopback` (and `--passphrase-file` when a passphrase exists) on every gpg invocation. Bundled CLI is windmill-cli@1.705.0. Verified via reproducer at /tmp/git-sync-diff/test-gpg-fix.sh: deliberately killing gpg-agent between GPG setup and `git commit` reproduces the customer's `gpg failed to sign the data` error verbatim under the old flow, and the wrapper signs through it. Holds for passphrase-protected keys, split-subkey [C]+[S] layouts, and unprotected keys. Drops the local source-of-truth copy (`hub-scripts/`) — hub is canonical now that 28234 is published. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(git-sync): drop verbose comment above LATEST_GIT_SYNC_SCRIPT_PATH The git history (this PR) carries the why; the constant name + value carry the what. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cli): wmill sync git-deploy stops committing; caller owns commit+push (#9284) Single contract for the deployment-callback path: the CLI does branch checkout + pull, the caller (hub script in production, test in test) does git add + commit + push. This restores the WIN-1974 invariant — GPG setup and `git commit` run back-to-back in the same process, so the agent's pre-warmed passphrase cache is still warm at sign time — without needing a `--skip-commit` flag for the hub case and a default "also-commit" for everything else. Same behavior in every call site. Changes: - sync.ts: drop the gitSyncDeployPush call from pull()'s deploy path (both the onlyCreateBranch fast-return and the post-pull commit). `gitSyncDeployPush` stays exported for any caller that wants the same commit/push semantics — just not invoked by the CLI subcommand. - gitsync_promotion.test.ts: e2e test now does its own git add + commit + push after `wmill sync git-deploy`, mirroring what the hub script does in production. Same regression coverage (wm_deploy branch created in Case A, main untouched; main updated in Case B, no new wm_deploy). CLI typecheck unchanged (two pre-existing TarAsZip errors at lines 2578/3307, present before this PR). All 743 unit tests still pass. The accompanying hub script (option-C — CLI for branch+pull, script for commit+push) lives at /tmp/git-sync-diff/sync-script-to-git-repo-windmill.option-C.ts. Once published, a follow-up bumps LATEST_GIT_SYNC_SCRIPT_PATH to its id. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * bump git sync to 28236 * fix: fork compare visibility for non-admins and stale-token superadmins (#9283) * fix: use fork-scoped authed for fork visibility in compare_workspaces * test: add EE end-to-end repro for fork rename visibility * chore: restore concurrency_locks sqlx cache lost in cleanup * test: add regression for stale-superadmin-token fork visibility bug * chore: update sqlx cache for new test queries * chore(main): release 1.706.1 (#9281) * chore(main): release 1.706.1 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> * feat: add wmill job rerun subcommand (#9275) * feat: add wmill job rerun subcommand * feat: add wmill job restart subcommand for flow restart-at-step * chore(system_prompts): point plugin skills sync at plugins/windmill/ (#9287) * chore(system_prompts): point plugin skills sync at plugins/windmill/ The plugin checkout's plugin folder is being renamed from `plugins/windmill-code-plugin/` to `plugins/windmill/` to shorten the slash-command namespace and align with the matching Cursor plugin layout. Paired with windmill-labs/windmill-claude-plugin#8. That PR must merge first so the next sync run finds the new folder. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(system_prompts): update plugin-dir example to plugins/windmill Co-authored-by: centdix <centdix@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: centdix <centdix@users.noreply.github.com> * fix(cli): wmill sync pull updates wmill-lock.yaml for raw apps (#9289) * fix: flow recording teardown crash + rename package to @windmill-labs/components (#9288) * fix: guard against null recording during FlowRecordingReplay teardown Navigating away from a flow recording inside a workspace file-tree view threw `TypeError: Cannot read properties of null (reading 'flow')` from FlowGraphViewer once during the teardown tick. Svelte 5 compiles child component props as live getters that close over `$$props.recording.flow`. When `recording` flips to null on the parent's navigation, an outer `{#if !recording?.flow}` doesn't stop those getters from firing one more time as derived effects re-evaluate before the unmount lands — so the getter dereferences null and throws. Fix at the two layers where the deref actually happens: - FlowRecordingReplay: use `recording?.flow` at the binding sites (FlowViewer + graph-snippet FlowGraphViewer) so the compiler emits an optional-chained getter, and guard the snippet branch with `{:else if recording?.flow}` so it doesn't mount when there's nothing to show. - FlowGraphViewer: finish the optional chaining the rest of the file already used everywhere else (`flow?.value?.skip_expr`, `flow?.value?.cache_ttl`, `flow?.schema`). When the upstream binding returns undefined during teardown, the graph degrades to an empty frame instead of crashing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: rename package to @windmill-labs/components - frontend/package.json: rename `windmill-components` → `@windmill-labs/components` - frontend/publish.sh: drop the in-place sed rename dance; the checked-in name now matches what's published, so `npm run package && npm publish` is enough - frontend/package-lock.json, system_prompts/auto-generated/prompts.d.ts: regenerated by `npm run package` under the new name Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(flows): restore Variables and Resources in flow editor prop picker (#9290) The design system overhaul in888837431caccidentally dropped the fallback condition that displayed the Variables and Resources sections in the prop picker by default. After that commit, these sections only appeared when the user typed `variable.` or `resource.` in their expression, which meant they effectively disappeared from the flow editor's prop picker for most users. Restore the previous behavior by showing the sections when no input match is active (the equivalent of the old `!filterActive` clause). Fixes WIN-1976 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): tighten token-owner fallback for unscoped tokens (WIN-1978) (#9293) * fix(auth): reject unscoped tokens with cross-workspace forged owners (WIN-1978) An unscoped token (workspace_id IS NULL) whose `owner` field references a user, group, or unprefixed value that is not present in the target workspace must not authenticate. The previous fallback in the `u/<username>` branch granted `(is_admin=false, is_operator=true)` when no `usr` row matched in the target workspace, letting a token holder who could mutate the `token` table cross workspace boundaries with operator privileges. The `g/<groupname>` branch likewise silently accepted any group name as a "group user", and the no-prefix branch granted operator state from arbitrary owner strings. Both are now rejected unless the owner matches a real user/group membership in the target workspace. Adds an integration regression covering all three forged-owner shapes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: drop integration regression for auth fallback The test added in the previous commit relies on a sqlx::query! that requires offline-cache regeneration; removing per code-review preference to keep this PR scoped to the auth-layer fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ResourceEditor): don't reset state when `selected` reverts to undefined (#9295) The bootstrap effect tracked `selected` via its early-return check, so any time `selected` flipped back to `undefined` it would re-run and reinitialize `states[effectiveWorkspace]` to empty — wiping user input. This happens in the React SDK consumer: reactify re-syncs all Svelte props on every React render, and since `selected` isn't passed through, `$props()` reverts it. Move the `selected !== undefined` check inside the existing `untrack` so the effect only tracks `effectiveWorkspace`. Bootstrap still runs once on mount; subsequent `selected` flips no longer retrigger it. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(secret-backend): pass DB to Vault migrations + show failure details (#9292) * [ee] fix(secret-backend): pass DB to Vault migrations + surface failure details Companion to windmill-ee-private fix for WIN-1977. The HashiCorp Vault migration always failed under JWT/OIDC auth because the migration constructed VaultBackend without a DB, so every secret hit "Database connection required for JWT authentication". Creating new secrets worked because the runtime path passes the DB. Frontend: when failed_count > 0, the toast and console now show the per-secret failures (path + error, capped at 5 with "...and N more") instead of just aggregate counts. Fixes WIN-1977 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to 14315067c083d3361512de621b12e41dbe3b017d This commit updates the EE repository reference after PR #587 was merged in windmill-ee-private. Previous ee-repo-ref: 390ed6c851b1915f0b492897c663f8058477680f New ee-repo-ref: 14315067c083d3361512de621b12e41dbe3b017d Automated by sync-ee-ref workflow. * fix(secret-backend): escape failure fields and use <br> in migration toast Address CI review on PR #9292: - P1 (cubic/codex): backend-supplied workspace_id/path/error are now HTML-escaped before being interpolated into the migration toast, which renders through {@html processMessage(...)} in Toast.svelte. This prevents stored XSS via secret paths or backend errors that contain markup. '/' is intentionally left intact so the toast's path-highlight regex still tags workspace paths. - P2 (pi): swap '\n' for '<br>' so multi-line failure lists actually break in the toast instead of collapsing to a single run-on line. - Extend the same per-secret failure surfacing (toast + console.error) to the Azure Key Vault and AWS Secrets Manager migration handlers via a shared reportMigrationFailures() helper so all six migration paths report identically. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com> * nit react-sdk resource editor * sdk_resource * make `selected` resilient + snapshot args for React (#9298) * fix(ResourceEditor): make `selected` resilient + snapshot args for React Two issues surfaced via the React SDK (reactify wrapper re-spreads Svelte props on every host re-render): 1. The bindable `selected` prop transiently resets to undefined on each re-spread, flipping `current` through undefined and unmounting the form (input loses focus on every keystroke). Rename the prop to `selectedProp` and derive `selected = selectedProp ?? effectiveWorkspace` so the fallback insulates the component without effects. 2. The onChange dispatch passed `current.args` (a `$state` proxy) directly, so React consumers diffing by reference or JSON.stringify saw the same value forever, and the effect only tracked the args reference (not nested mutations). Wrap with `$state.snapshot` to deep-track and emit a plain object. The bootstrap effect is also restructured: it no longer writes `selected` (the derived handles defaulting) and now guards on `selected in initialStates` so workspace flips remain idempotent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ResourceEditor): declare effectiveWorkspace before use in selected Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * remove unused workflow * feat(typescript-client): add deleteS3File + optional workspace arg on S3 helpers (#9300) * feat(typescript-client): add deleteS3File + optional workspace arg on S3 helpers Customer-requested ergonomics for the TypeScript SDK: - New `deleteS3File(s3object, workspace?)` wrapper around the existing `HelpersService.deleteS3File` (backend endpoint is already there). Saves callers from having to either hand-roll `denoS3LightClientSettings()` + AWS SDK calls, or wire up `HelpersService` directly. - `denoS3LightClientSettings`, `loadS3File`, `loadS3FileStream`, `writeS3File`, and the new `deleteS3File` all gain an optional trailing `workspace?: string` parameter that falls back to the `WM_WORKSPACE` env var via `getWorkspace()`. Mirrors the calling convention customers already expect from helpers like `getVariable` / `runScript`. `build.sh` and `build.jsr.sh` are updated to export `deleteS3File` from both the NPM and JSR entry points. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: regenerate system_prompts auto-generated for new S3 helpers `python system_prompts/generate.py` after adding deleteS3File and the optional workspace param to the existing S3 helpers, so the agent-facing docs (CLI skills, TS SDK prompt, script skills) reflect the new signatures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(github-app): hide cloud-only UI on self-managed + admin assignment UI (#9299) * feat(github-app): hide cloud-only UI on self-managed + admin assignment UI Two related UX fixes for the GitHub App self-managed (GHES) integration: 1. On self-managed instances, the per-installation Export button and the "Import installation from other instance" section in the workspace UI both hide. Both round-trip a JWT carrying only {installation_id, account_id} with no github_base_url, so they would produce broken cloud-style installs on a self-managed instance. The previous Export attempt also failed with "No JWT token received from server" because self-managed installs store an empty JWT by design. 2. New "Workspace assignments" panel in instance settings (GhesAppSettings.svelte) that auto-discovers installations of the configured GHES App and lets the super-admin assign them to specific workspaces. Workspace users without GitHub permissions no longer need to install the App themselves — the admin provisions the link from instance settings. Admin-provisioned installs show a "Provisioned by admin" badge in the workspace UI and can only be removed by the super-admin from instance settings. Backend support is in the EE companion PR windmill-labs/windmill-ee-private#588. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to da5189cf69a453de3855057f41be0d84e5910707 This commit updates the EE repository reference after PR #588 was merged in windmill-ee-private. Previous ee-repo-ref: d959b83ce413ad531e9cc28e0f8199cdecb73a31 New ee-repo-ref: da5189cf69a453de3855057f41be0d84e5910707 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com> * chore(main): release 1.707.0 (#9285) * chore(main): release 1.707.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> * feat(queue): per-workspace fairness cap on the shared cloud worker pool (#9303) * feat(queue): cloud-only per-workspace fairness cap on the shared worker pool On `app.windmill.dev` the cluster runs a single default worker group, so a single workspace flooding the queue can degrade quality of service for everyone else. This adds an opt-in mechanism that caps any single workspace at a configurable share of the shared worker pool when it has been dominating cluster activity for more than a configurable window. Detection signal counts both currently-running jobs and jobs completed in the rolling window, so it catches workspaces hogging slots with long jobs **and** workspaces spamming many tiny jobs (where no individual job's started_at is old, but throughput share dominates). Refresh is coordinated cluster-wide via a single UPDATE on `background_task_state`: the `WHERE updated_at < now() - interval` predicate combined with row-level locking means only one process per refresh cycle actually runs the aggregation, regardless of fleet size. Every other process gets the freshly written value in the same round trip via `UNION ALL ... LIMIT 1`. Heavy aggregation rate stays at ~0.2-0.5 qps for the whole cluster. Pull queries are split: the existing query string and its bind shape stay bit-identical to today, so the planner keeps using the same indexes when fairness is off or no workspace is currently capped. A separate `WORKER_PULL_QUERIES_FAIRNESS` adds `AND workspace_id <> ALL($2::text[])` and is only materialized while the feature is enabled. Hard-gated to `CLOUD_HOSTED=true` + BASE_URL host == app.windmill.dev at three layers: frontend `cloudonly: true`, API setter rejection in `set_global_setting_internal`, runtime check in `fairness_active`. Settings are exposed under Jobs in the instance-settings UI; defaults are off so the change is a no-op for self-hosted. Two-pass pull guarantees no worker idling: if every queued job belongs to a capped workspace, the second pass uses the unmodified pull queries. Cap re-asserts on the next refresh. Fixes WIN-1982 * fix(queue): address CI review findings on workspace fairness Six fixes from the four-reviewer cross-check on #9303: 1. **Aggregation evaluation (Codex P1).** The previous `INSERT ... ON CONFLICT DO UPDATE WHERE updated_at < ...` had the heavy `v2_job_queue ∪ v2_job_completed` aggregation inlined into `VALUES`, which Postgres evaluates for every contender to build the proposed row — losing the "one heavy aggregation per cycle cluster-wide" property the design advertises. Split into three small statements: (a) cheap claim with constant `VALUES`, (b) winner-only `UPDATE ... SET value = jsonb_build_object('overloaded', <agg>)` (Postgres only evaluates `SET` per row matching `WHERE`, so losers never compute the aggregation), (c) read for everyone. Heavy query now truly runs ~0.2-0.5 qps cluster-wide regardless of fleet size. 2. **Numeric setting wraparound (cubic P1).** `u64 as u32` and downstream `u32 as i32` could silently flip sign and feed `make_interval(secs => -N)`, making `now() - interval` a future timestamp and disabling the completed-jobs half of the activity signal. Clamp `duration_secs` to [1, 86400] and `min_total_jobs` to [0, u32::MAX] before storing. 3. **`/instance_config` bypass (cubic/Claude/Codex P2).** Bulk config endpoint sidestepped `set_global_setting_internal`'s gate; a self-hosted superadmin could persist `workspace_fairness_*` rows via the bulk path. Mirror the per-key check in `set_instance_config` upsert flow. 4. **DB error coerced to false (Claude P2).** `load_workspace_fairness_enabled` collapsed `Err(_)` to `false` and unconditionally swapped the atomic — a transient DB blip during notify-event propagation toggled the feature off cluster-wide (and triggered a `store_pull_query` rebuild precisely when load is highest). Now propagates the error so the atomic stays at its prior value. 5. **Refresh failure cooldown (Claude P2).** Storing `0` removed the rate limit entirely; every subsequent pull spawned a new refresh task. Leave `LAST_REFRESH_MICROS` at `now_us` (already written by the CAS) so the natural interval acts as the cooldown. 6. **Visibility + duplication (Pi P2).** Mark `make_pull_query_fairness` as `pub(crate)`. Move the duplicated `BASE_URL host == app.windmill.dev` parser into `windmill-common::worker::is_cloud_production_host` and share it between the API setter and the runtime path. Verified locally: - `POST /api/settings/global/workspace_fairness_enabled` → 400 (per-key gate) - `PUT /api/settings/instance_config` with fairness key → 400 (bulk gate) - `cargo check --workspace --features=private,enterprise,quickjs` — clean Refs WIN-1982. * fix(queue): second round of CI review nits on workspace fairness Three issues raised by the Codex/Claude re-review of commit0b38ff2: 1. Non-cloud deletes were rejected (Codex P2). The cloud gate ran before the Null / empty-string deletion branches in both `set_global_setting_internal` and the bulk `set_instance_config`. A self-hosted instance that inherited stale `workspace_fairness_*` rows from a cloned cloud DB couldn't clear them through the API — the rows stayed in `global_settings` and continued to show up in the YAML export. Now the gate only blocks upserts; Null / empty-string deletes pass through on any host. 2. Deleted numeric knobs kept stale runtime values (Codex P2). When a cloud admin cleared `workspace_fairness_max_percent`, `..._duration_secs`, or `..._min_total_jobs`, the notify-event fired but the numeric loaders ignored `Ok(None)` and left the previous in-memory value pinned until process restart. Loaders now distinguish three outcomes: - `Err(_)`: transient — leave atomic alone (preserves the previous-round fix). - `Ok(None)` / `Ok(Some(invalid))`: reset to the documented default. - `Ok(Some(valid))`: clamp and store. Defaults are extracted to `WORKSPACE_FAIRNESS_*_DEFAULT` constants kept in sync with the `AtomicU32::new(...)` initialisers in `windmill-common/src/worker.rs`. 3. `fairness_active` was `pub` with no cross-crate caller (Claude nit). Tightened to module-private. Verified locally on this non-cloud instance: POST .../workspace_fairness_enabled body=null → 200 (delete passes) POST .../workspace_fairness_enabled body=true → 400 (set blocked) PUT .../instance_config {} → 200 (no-op passes) PUT .../instance_config with fairness key → 400 (bulk set blocked) Skipped the partial index on `v2_job_queue WHERE running = true` that Claude flagged as a residual nit — queue stays under 50k rows per the operator's measurement, so the seq-scan cost (~10 ms × 0.5 qps = ~0.5% of a DB core) is well below the noise floor and the index isn't worth the maintenance cost on job transitions. Refs WIN-1982. * chore(main): release 1.708.0 (#9304) * chore(main): release 1.708.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> * feat: add copy button to Path component (#9311) * feat: plug global chat drafts into userdraft (#9291) * refactor: move global chat drafts to userdraft * feat: share script and flow drafts with editors * feat: share trigger drafts with editors * feat: share raw app drafts with editor * feat: share resource drafts with editors * docs: rename global chat drafts copy * feat: add global chat draft discard tool * fix: resolve global chat editor draft paths * fix: remove editor draft path resolver * feat: track live editor drafts in userdraft * fix: snapshot live userdraft reads * chore: checkpoint pending global draft changes * fix: address global draft review issues * fix: defer raw app draft persistence * docs: remove pr investigation docs * fix: persist live global draft writes * refactor: move bedrock proxy handling to windmill-ai (#9309) * refactor: move bedrock proxy handling to windmill-ai * docs: track ai refactor follow-ups * fix(auth): filter resource/variable listings by token scope (WIN-1981) (#9302) A token scoped to a single resource (e.g. `resources:read:u/alice/foo`) could call `GET /api/w/{w}/resources/list_search` and receive `path` and `value` for unrelated resources in the workspace. Route-level scope checks only validate `domain:action`; per-resource handlers do a `check_scopes` against the path, but the listing endpoints did not — leaking integration credentials, API keys, and other secrets stored as resource values to narrowly-scoped tokens. Add `build_scope_path_predicate` to `windmill-api-auth` (mirrors `check_scopes` semantics but parses the token's scopes once, suitable for filtering many rows). Apply it to `list_search_resources`, `list_resources`, `list_names` (resources) and `list_variables` (non-secret value leak), so a scope-restricted token only ever sees the paths it is authorized to read. Unscoped tokens and tokens whose only scopes are `if_jobs:filter_tags:*` are unaffected. Includes regression tests covering: unscoped, tag-filter-only, single-resource, wildcard, wrong-domain, and write-implies-read. Fixes WIN-1981 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * audit-log workspace-fairness cap transitions (#9306) * feat(queue): audit-log workspace-fairness cap transitions When the cloud per-workspace fairness mechanism adds a workspace to the capped set or releases one, write `workspace_fairness.capped` / `workspace_fairness.uncapped` audit-log entries to the affected workspace. The cluster admin can review the full timeline from the `admins` workspace audit view with `all_workspaces=true`; per-workspace owners see their own events in their normal audit list. Only the per-cycle refresh winner emits entries (matching where the heavy aggregation runs), so a fleet of N workers does not produce N duplicates per transition. The diff is computed against the value already in `background_task_state` rather than the winner's in-memory cache, so a freshly-restarted process winning the claim does not spuriously emit "newly capped" entries for workspaces that were already capped before it started. Audit writes are best-effort: failures are logged via tracing and do not abort the refresh cycle. Fixes WIN-1984 * feat(queue): scope fairness audit to admins workspace + queue-metrics pane - Write `workspace_fairness.capped` / `workspace_fairness.uncapped` to the `admins` workspace (was: per-affected-workspace) with the affected workspace_id moved to the `resource` field. Cluster admins now get the full timeline in one place without `all_workspaces=true`. - Add `GET /workers/workspace_fairness_events` returning the last 100 events. Cloud-gated (returns `[]` on non-cloud) and devops-only. - Add a `WorkspaceFairnessEvents` Section to the Queue Metrics drawer, rendered only when `isCloudHosted()` is true. Shows time / event badge / workspace / parameters with a refresh button. Fixes WIN-1984 * feat(ai-chat): expand chat question answers (#9310) * feat(ai-chat): align footer bar + DropdownV2 mode/autonomy selectors (#9308) * feat(ai-chat): align footer bar, use DropdownV2 for mode/autonomy selectors Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(dropdown): add `selected` item prop rendering a trailing check Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(ai-chat): add small spacing between chat input and footer bar Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ai-chat): always offer the 3 autonomy options in the auto-accept picker Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai-chat): default autonomy mode to auto-accept on Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(ai-chat): use Button component for footer dropdown triggers Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(ai-chat): use a hand icon for the auto-accept-off autonomy state Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(ai-chat): use subtle Button variant for mode and model selectors Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(ai-chat): tighten spacing between input and footer bar Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ai-chat): reword autonomy levels as ask/auto-accept/bypass permissions Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(button): add 2xs unified size with tighter padding Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ai-chat): compact footer bar — 2xs buttons, AtSign context icon, short Yolo label, discreet model Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(ai-chat): widen the permission selector dropdown Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(dropdown): group shortcut + selected check to avoid ml-auto collision Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(ai-chat): cover getPersistedAutonomyMode default; clarify default comment Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(raw_apps): tab-based editor surface with split-with-preview (#9273) * feat(raw_apps): custom tab system for source / runnable / preview Replaces the fixed split-pane layout with a tab bar inside the editor area. Each frontend file is a tab, each selected runnable is a tab, and the Preview is pinned to the right (non-closable). Tabs are an alternative discoverability surface to the sidebar — both stay functional, but tabs make navigation viable on small screens with the sidebar collapsed. A "Split with Preview" toggle in the tab bar's trailing slot pairs the active tab with the preview side-by-side for wide-screen multitasking. The toggle hides when Preview is already the active tab. The UI Builder, runnable editor, and preview iframe all stay mounted across tab switches (toggled via `display`) — no bundler restarts, no preview state loss, no editor remounts. - New common/tabs/DraggableTabs.svelte: reusable tab strip with drag-reorder (@windmill-labs/svelte-dnd-action), pinned-left/right slots excluded from the drag zone, hover-revealed X close, middle- click close, keyboard navigation (arrows / Enter / Backspace), and a `trailing` snippet for inline toolbar add-ons. - raw_apps/RawAppEditor.svelte: - Tab state (`tabs`, `activeTabId`, `splitWithPreview`) lives in Windmill. Persisted in localStorage keyed by workspace + app path. - Sidebar file clicks (`handleSelectFile`) and runnable selection (`selectedRunnable` via `bind:`) are mirrored into tabs via an effect — the sidebar interaction is otherwise untouched. - Listener augmented: `setActiveDocument` backfills tabs for files VS Code opens by itself; `setFiles` / `runnables` updates drop stale tabs. - Bundler / inspector / rebuild toolbar moves into the tab bar's trailing slot — always visible regardless of active tab. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(raw_apps): modern tab styling + resizable split-with-preview Two polish passes on the new tab system: DraggableTabs styling: - Remove the bottom border on the tab strip + the accent-coloured border-b-2 on the active tab. The active tab now shares the surface background with the content area below it, so the boundary visually "disappears" — modern IDE-style tabs. - Inactive tabs sit on the darker surface-secondary tab strip and get a subtle right separator so they don't blur into each other. Split-with-Preview is now a real resizable Splitpanes: - The content area is rendered as a Splitpanes (always), with the source/runnable slot on the left and the preview iframe on the right. The user can drag the divider to adjust the ratio when the "Split with Preview" toggle is on. - Iframes never remount across single↔split toggles — pane sizes are driven reactively from (activeTabKind, splitWithPreview), not by adding/removing the Splitpanes itself. - The user's preferred split ratio is remembered while they're dragging and reapplied next time split is enabled. - The inner splitter is CSS-hidden in single mode so the toggle button stays the single canonical way to flip layouts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(raw_apps): split mode moves preview tab into the right pane Cleaner mental model for split-with-preview. Instead of "split the active tab + always keep the Preview tab around", the Split toggle now physically moves the Preview tab out of the bar and into a permanent right pane. When the user toggles split off, the Preview tab reappears in the bar like any other tab. - New `displayedTabs` derived: filters out the Preview tab when splitWithPreview is on, so the user sees only file/runnable tabs in the bar and a dedicated preview pane on the right. - `toggleSplit` redirects the active tab to the most recent file/runnable when the user toggles split on with Preview active, so they don't end up staring at an empty left pane. - Split toggle is now always visible — the user can flip both ways. The button label flips between "Pin preview to the right" and "Move preview back into a tab" to reflect what's about to happen. - reorderTabs preserves the Preview tab in the underlying `tabs` array even though it's filtered out of the drag set in split mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(raw_apps): VS Code-style "Preview" header on the right pane In split mode, the right pane now shows a small "Preview" tab-styled header anchored at its top-left — making the layout read like a real VS Code editor split, where each group has its own tab bar. - Header appears only when `splitWithPreview && activeTabKind !== 'preview'` (i.e. when the right pane is meaningfully separate from the left's content). In single mode with preview active, the right pane is the only thing visible and the main tab bar already labels it. - The header uses the same styling as an active tab: `bg-surface` on a `bg-surface-secondary` strip, h-8, text-xs, no border. - An X button next to the label toggles split off — equivalent to closing the editor in VS Code's split view (preview goes back to living as a tab in the main bar). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(raw_apps): VS Code-style symmetric tab bars per pane Restructure the editor area so each pane is a self-contained "editor group" with its own tab bar at the top. The Splitpanes is now the topmost element — the divider runs floor-to-ceiling, splitting both the tab bars and the content. Layout (left pane = source / runnable, right pane = preview): - Left pane top: DraggableTabs (file/runnable tabs, Preview tab when split is off) + Split-toggle in the trailing slot. - Right pane top: a custom preview header — "Preview" label styled like an active tab on the left + the preview-affecting toolbar (bundler, inspector, rebuild) on the right. - Each pane independently sized via Splitpanes; iframes + the runnable panel stay mounted and toggled via `display` so state survives every transition. Trade-off: in single-mode with Preview active (paneA=0), the left tab bar is hidden along with the left pane. To switch back to a file tab the user uses the sidebar — which is exactly the discoverability surface tabs were meant to complement, not replace. Button placement by semantic ownership: - Layout control (Split toggle) — left side, with the editor. - Preview-affecting controls (bundler, inspector, rebuild) — right side, with the preview. No close-X on the right; the Split toggle on the left is the canonical way to flip layouts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(raw_apps): keep tab bar visible when Preview is active in single mode The "VS Code-style" restructure put the tab bar inside the left Pane. When activeTabKind became 'preview' in single mode, the left pane collapsed to width 0 and the entire tab bar disappeared with it — leaving the user with no way to switch back to a file tab except via the sidebar. Move the main tab bar back above the inner Splitpanes (full width, always visible). The preview pseudo-header stays inside the right pane, carrying the bundler / inspector / rebuild toolbar. The splitter only goes through the content area below the tab bar, which is acceptable given how much friction the disappearing-tabs edge case caused. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(raw_apps): per-pane tab bars with mirrored single-mode lists Replace the single tab bar above the inner Splitpanes with one DraggableTabs per pane. Splitter now goes floor-to-ceiling through tabs AND content in split mode. In single mode both bars mirror the full tab list, so the visible pane always carries every tab — fixes the bug where activating Preview hid the tab strip. Clicking Preview while in split mode is a no-op (Preview is permanently visible in the right pane). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(raw_apps): polish tab strip and sync editor font to text-xs * feat(raw_apps): move logs overlay onto the preview pane * refactor(splitpanes): extract pixel-aware minSize helper * fix(raw_apps): tab hydration loads correct file; closeTab in split mode * fix(raw_apps): lazy-mount UI Builder iframe + add dev:ui-builder script * feat(raw_apps): default split view, blue preview tab, fix dnd ghosting * fix(raw_apps): remove 1px splitter sliver beside preview in single view * fix(raw_apps): tab scrollbar on hover, fix thumb height + resize staleness * refactor(raw_apps): don't persist tab/split layout in localStorage * refactor(raw_apps): derive pane sizes + binding setter instead of effects * style(raw_apps): trim verbose comments * feat(raw_apps): accept appendLogs delta from the UI Builder iframe * fix(raw_apps): exit inspect mode on Escape * fix(raw_apps): Escape clears lingering inspector selection after pick * style(raw_apps): accent-selected styling for active tab, bg-surface strip * fix(raw_apps): address PR review nits (drop debug log, timer/reorder/pane-setter, dev script restore) * fix(raw_apps): clear inspector overlay on the preview iframe, not the source * style(raw_apps): neutral tab look (surface-tertiary/text-emphasis selected, text-hint idle) * chore(raw_apps): bump bundled ui_builder to 61b6fdd --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(raw_apps): bump bundled ui_builder to b4f6219 (#9314) * skip workspaced-route duplicate checks on cloud (#9305) * fix(settings): skip workspaced-route duplicate checks on cloud The pre-write validation hooks for `app_workspaced_route` and `http_route_workspaced_route` query the DB for cross-workspace duplicates and fail the save when any are found. On cloud both `custom_path_exists` (apps) and `route_path_key_exists` (HTTP triggers) already scope lookups by `workspace_id` regardless of these settings, so duplicates across workspaces are expected and the validation has no runtime meaning. The result was that any cloud super-admin attempting to save instance settings with these toggles set to false received `Duplicate HTTP route paths detected` even though the setting has no effect on cloud routing. Fixes WIN-1983 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(error): render JsonErr as readable text and return 400 `Error::JsonErr` previously rendered through `#[error("Error: {0:#?}")]`, leaking Rust's `Debug` output (`Object { "error": String(...), "details": Array [...] }`) into the HTTP response body, and was bucketed into the catch-all 500 branch in `IntoResponse`. The result was a 500 status with a wall of Rust debug syntax in the toast — confusing and user-hostile. - Bucket `JsonErr` into 400 (Bad Request): every current call site (workspaced-route duplicate checks, OAuth client errors, etc.) is a client/validation issue, not an internal server fault. - Add `format_json_err_message` which surfaces the `error` field as the headline, summarises `details` (with a `- key=value` per entry), and pretty-prints the rest as JSON for unknown shapes. The frontend toast now reads e.g. Duplicate HTTP route paths detected - route_path=a, workspace_id=admins, http_method=post - route_path=a, workspace_id=starter, http_method=post Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(toast): preserve newlines and escape HTML in multi-line errors The toast renders via `{@html processMessage(message)}`, so server-side error bodies that span multiple lines (e.g. the duplicate-route response from the settings endpoint) collapsed into a single line because HTML treats consecutive whitespace (including `\n`) as a single space. When the message contains a newline, escape HTML first (defends against injected markup in server error bodies) and convert `\n` to `<br />` so multi-line errors stay readable in the toast. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fixup: address CI review feedback - toast.ts: escape HTML unconditionally. The previous gate on `\n` left single-line server error bodies unsafe under {@html}, which cubic flagged as P0. The path regex below only inserts a `<span>` around a `u/...` or `f/...` capture that can't contain HTML metacharacters, so escaping the whole input is the simpler and correct fix. - error.rs: add unit tests pinning the rendered shape of `format_json_err_message` (error+details, error-only, truncation cap, non-object fallback to pretty JSON). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(service-accounts): allow choosing role at creation time (#9307) * [ee] feat(service-accounts): allow choosing role at creation time Previously, service accounts were hardcoded to operator and could not be used as the CLI sync user since they had no write access. They also only counted as 0.5 seat each. This change: - Extends `NewServiceAccount` to accept optional `is_admin` / `operator` (defaults to `operator=true` for backward compatibility). - Exposes a role picker in `AddUser.svelte` when creating a service account (Operator / Developer / Admin). - Lets admins update a service account's role from the user list (it used to be locked to "Operator" with a tooltip). - Updates the OpenAPI spec + regenerates the frontend client. A developer/admin service account counts as 1 seat under the existing seat-cap logic (operators stay at 0.5). Companion PR on windmill-ee-private updates the `INSERT INTO usr` to honour the chosen role. Fixes WIN-1985 * [ee] feat(service-accounts): wm_deployers opt-in for Dev role When creating a service account with role=Developer, surface a toggle "Add to wm_deployers" (recommended). Members of wm_deployers can deploy on behalf of other users — the typical setup when the service account is used as the CLI sync / CI deploy identity. - `NewServiceAccount` gains an optional `add_to_deployers` flag. - Frontend defaults the toggle to on but only shows it under Developer (admins have it implicitly; operators can't deploy). - Tooltip links to docs.windmill.dev "Run on behalf of". Companion EE PR updates the handler to INSERT into usr_to_group for wm_deployers when the flag is set. Refs WIN-1985 * chore: update ee-repo-ref to 974ed42067d9f63acb42332b671b8c01ffd4b625 This commit updates the EE repository reference after PR #589 was merged in windmill-ee-private. Previous ee-repo-ref: f7dbc3cc2ba21c396f4828881e3b9d9ab6f50c69 New ee-repo-ref: 974ed42067d9f63acb42332b671b8c01ffd4b625 Automated by sync-ee-ref workflow. * [ee] fix(service-accounts): unhardcode role in superadmin user list Two review issues from the merged #9307 / #589: 1. P1 — The global Users tab in #superadmin-settings still pinned every service account to "Operator". Now it shows the actual role (Admin / Operator / Developer), derived from the SA's usr row. - `list_users_as_super_admin`: replaced `true as operator_only` with the real `operator` value, and added `is_workspace_admin` from the row (NULL for password users since their admin status is per-workspace). - `global_whoami`: when the email belongs to a service account, look up its real `operator` / `is_admin` instead of pinning to operator. - `SuperadminSettingsInner.svelte`: drop the hardcoded "Operator" badge; render Admin / Operator / Developer using the new fields, matching the workspace-level view. 2. P2 — Regenerate the bundled `openapi-deref.{yaml,json}` so the `createServiceAccount` body (now exposing `is_admin`, `operator`, `add_to_deployers`) and the new `GlobalUserInfo.is_workspace_admin` field show up at runtime in `/api/openapi.{yaml,json}`. Bumps `ee-repo-ref.txt` to the EE follow-up that adds the offline seat-cap check on `create_service_account`. Refs WIN-1985 * chore: update ee-repo-ref to b7a6068c1f3dc845e012959268b2426f0de4d697 This commit updates the EE repository reference after PR #590 was merged in windmill-ee-private. Previous ee-repo-ref: 0b1307c21d1bfd6fb43a03c2ba39d2a8bf8e6470 New ee-repo-ref: b7a6068c1f3dc845e012959268b2426f0de4d697 Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com> * fix(jobs): authorization bypass in only_result job updates (WIN-1980) (#9301) * fix(jobs): enforce anonymous-only guard on `only_result` job updates The `jobs_u/getupdate/{id}` and `jobs_u/getupdate_sse/{id}` endpoints accept `only_result=true`. In that branch, `get_job_update_data` queried the result solely by (workspace_id, job_id) and skipped the `created_by == "anonymous"` check that the non-only_result path and adjacent unauthenticated endpoints apply. An unauthenticated requester who learned a private job UUID could therefore retrieve that job's output. Hoist the guard to the top of `get_job_update_data` so both branches are covered. Fixes WIN-1980 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: fold `created_by` check into existing only_result queries Avoids the extra `SELECT created_by` round-trip per call by joining `v2_job` once in the two queries that handled the unauth path and checking inline. Behavior is identical to the prior commit; the SSE polling loop now does one query per poll instead of two for unauthenticated callers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: cache anonymous_verified across SSE polls Replace the LEFT JOIN approach with an upfront `SELECT created_by` guarded by a new `&mut bool anonymous_verified` parameter that mirrors `early_return_suppressed`. The SSE polling loop now performs the auth check exactly once per stream rather than per poll, and the data SQL reverts to its original form so authenticated callers pay no extra cost. `created_by` cannot change after job creation, so caching the verification across polls is safe. Cost matrix: - Authed (any path): 0 extra queries - Unauthed one-shot: 1 extra query (unavoidable) - Unauthed SSE: 1 extra query at stream start, 0 per poll Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: scope anonymous check to only_result branch The non-only_result branch already enforces the `created_by` check via its main query, so a top-level hoisted check duplicated work for unauthenticated default-path callers. Move the check inside the `if only_result.unwrap_or(false)` block — exactly where the bypass lives — and leave the non-only_result path untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(raw_apps): surface UI Builder build errors over the preview pane (#9316) * feat(raw_apps): surface UI Builder build errors over the preview pane Companion to the matching change in the UI Builder repo (see linked PR), which stops rendering the build-error overlay over the VS Code editor iframe and instead emits a `buildError` postMessage on every build (message: undefined on success to clear). Listen for that message on the existing window message handler (already source-gated by the UI Builder iframe), store it in a `buildError` $state, and surface it in two places: * A red banner over the preview iframe, sibling to the existing logs overlay (`top-12 left-2 right-2 z-20` so it clears the tab bar) — failures appear right where the user looks for the rendered output. * The Preview tab's icon and label tint red (`text-red-600 dark:text-red-400`, matching the existing error convention in raw_apps) — important in single-tab mode where the preview pane is collapsed to 0px and the banner would be hidden. Done by mapping `leftPaneTabs` / `rightPaneTabs` through a small `tintPreviewOnError` helper so the source-of-truth `tabs` array is untouched (DnD, ordering, fallback selection keep using the original previewTab object). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(raw_apps): use Alert component for the build-error banner Replace the hand-rolled red div with the shared `Alert` component (`type="error"`, `title="Build failed"`). The error text stays in a `<pre>` child so multi-line bundler output keeps its formatting, with `max-h-60` so a long error never takes over the whole preview pane. The absolute-positioned wrapper (`top-12 left-2 right-2 z-20`) and the `role="alert"` move to that wrapper so the Alert component itself stays unstyled at the call site. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(raw_apps): solid bg-surface backing behind build-error Alert The Alert's error background is semi-transparent in dark mode (`bg-red-900/40` in `common/alert/model.ts`), so the preview iframe shows through when the banner is laid over it. Add a `::before` pseudo on the Alert root with `bg-surface` (matched `rounded-md`, `-z-10` so it sits behind the red bg) to give it a solid plate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(raw_apps): isolate banner stacking context, DRY tab tint chain Two small follow-ups from review: * Add `isolate` to the build-error banner wrapper so the `before:-z-10` pseudo's stacking context is pinned locally — it works today because `position: absolute` + `z-20` creates one, but `isolate` makes the dependency self-documenting and survives a future refactor that removes the explicit `z-20`. * Extract `tintTabs = (ts) => ts.map(tintPreviewOnError)` so the two `$derived` blocks for leftPaneTabs / rightPaneTabs read identically. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(raw_apps): trim build-error overlay comments Per review feedback. Keep only the load-bearing facts (bg-surface backs the Alert's translucent red, isolate pins the pseudo stacking, the `message: undefined` clear convention) and drop the prose context that duplicated what the code already shows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(raw_apps): bump bundled ui_builder to 00c9834 Brings in the postMessage emission from windmill-labs/windmill-code-ui-builder#9 (merged) so this PR's host listener actually receives `buildError` events. SHA verified against the R2 artifact. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(main): release 1.709.0 (#9312) * chore(main): release 1.709.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> * add cli-sync workspace snapshot/load scripts (#9322) * feat(fixtures): add cli-sync workspace snapshot/load scripts * fix(fixtures): address review nits (env var password, mktemp, dead refs) * fix(fixtures): address CI review (SIGPIPE, JSON escaping, doc/code drift) * feat(queue): stochastic admission + EE availability of workspace fairness algorithm (#9321) * refactor: unify AI provider credentials (#9317) * refactor: use provider credentials for worker builders * refactor: resolve api proxy credentials directly * fix: lazy load frontend eval modes * fix(websocket-trigger): honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY (#9324) * feat(websocket-trigger): honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY (WIN-1988) `tokio_tungstenite::connect_async` opens a raw TCP socket and ignores the standard outbound-proxy env vars, so deployments behind a forward HTTP proxy can't reach the WebSocket endpoint and Test Connection times out after 30s. Add a small `proxy` module that resolves the right proxy URL for the target host (HTTPS_PROXY for wss://, HTTP_PROXY for ws://, NO_PROXY exclusions, ALL_PROXY fallback, lowercase variants), opens an HTTP CONNECT tunnel when one applies, and hands the resulting TcpStream to `client_async_tls_with_config` for the TLS + WS handshake. Direct connect remains the default when no proxy env is set. Unit tests cover NO_PROXY matching, proxy URL parsing (including IPv6 literals and basic-auth userinfo), and the CONNECT handshake itself against an in-process fake proxy (success, basic-auth header, 407 rejection). Fixes WIN-1988 * refactor(websocket-trigger): reduce blast radius and reuse existing logic Follow-up to the proxy support change. Three things: 1. Skip the new code path entirely when no proxy is configured. `connect_async_with_proxy` now checks the env-var snapshots up front and delegates straight to `tokio_tungstenite::connect_async` if neither `HTTP_PROXY` nor `HTTPS_PROXY` is set. Same fall-through applies when proxy env is set but `NO_PROXY` excludes the host or the proxy URL doesn't parse. Non-proxied deployments now exercise exactly the previous code path. 2. Move the `NO_PROXY` / `HTTP_PROXY` / `HTTPS_PROXY` env-var snapshots from `windmill-worker::worker` into `windmill-common`. The worker's `PROXY_ENVS` static now reads from there, and the websocket trigger reads from the same source — one place reads the env, one source of truth for both call sites. 3. Replace the hand-rolled proxy-URL parser with `url::Url::parse` (already a workspace dep, used across the codebase). Half the LoC and handles edge cases (userinfo percent-encoding, IPv6 literals, path/query stripping) via the well-tested crate instead of by hand. All 13 proxy unit tests still pass. `cargo check` is clean. * fix(websocket-trigger): unbreak EE build + trim proxy tests - Re-export `NO_PROXY` / `HTTP_PROXY` / `HTTPS_PROXY` from `windmill-worker::worker` (via `pub use windmill_common::...`) so the EE `otel_tracing_proxy_ee` module's `use crate::{HTTPS_PROXY, ...}` resolves like it did before. Fixes the `check_ee_full` / `cargo_test` CI failures from the previous commit. - Trim the proxy tests to one un-ignored canary (`http_connect_tunnel_sends_well_formed_request_and_unwraps_stream`) that exercises the actual on-wire CONNECT handshake plus byte-perfect tunnel passthrough. The NO_PROXY-matching, URL-parsing, and edge-case tunnel tests are kept under `#[ignore]` for manual debugging (`cargo test -- --ignored`) since they're either delegated to `url::Url::parse` or trivial string matching — low ROI on every CI run. * chore(main): release 1.710.0 (#9323) * chore(main): release 1.710.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> * fix: improve workspace fairness * chore(main): release 1.710.1 (#9327) * chore(main): release 1.710.1 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> * prevent windows backend tests from running out of disk space (#9325) * ignore flaky fairness regression tests in CI (#9328) `fairness_ignores_zombie_running_rows` and `fairness_ignores_concurrency_suspended_rows` panic intermittently in CI (both Linux and Windows runs). Mark them `#[ignore]` until the underlying flakiness is resolved. * feat(cli): add object-storage commands and flow test-step (#9326) * feat(cli): add object-storage commands and flow test-step * docs(cli): clarify flow test-step doesn't recurse into aiagent tools * fix(cli): correct failure step id in docs, handle bare flow.yaml path * refactor(cli): fold flow test-step into flow preview --step (#9330) * fix(queue): duration-weighted workspace fairness signal (#9329) * fix(queue): bump EE ref to include worker_ping fairness signal The current ee-repo-ref.txt pointed to 31cda7c (an unrelated merge commit on the asset-graph-view-ee branch) instead of ddc9e80, which contains the workspace-fairness fix that switches the active-share signal from v2_job_queue.running=true to worker_ping. As a result cloud was still computing overload off the legacy signal, so a workspace with many in-flight/suspended flows (lancom01-prod, with 799 suspended flows × 3 v2_job_queue bookkeeping rows each = 2397 running-true rows) was flagged as 95% of cluster activity despite consuming zero worker slots. Bumping to ddc9e80 picks up the worker_ping-based signal, which naturally excludes (a) suspended jobs (no worker pinging them), (b) zombie running-rows from dead workers, and (c) flow/flownode orchestration rows that never run on a worker in the first place. * test(queue): seed v2_job rows + realistic durations for fairness helpers The new duration-weighted fairness algorithm joins v2_job_queue and v2_job_completed to v2_job for the `kind` filter (excluding flow bookkeeping) and reads `duration_ms` for the completed contribution. Update the test helpers to mirror that schema: * `insert_completed` now inserts a matching v2_job row (kind=script) and writes `duration_ms = 1000` with a 1-second [started_at, completed_at] interval, so each completed row contributes ~1 worker-second when fully inside the refresh window. * `insert_queued` likewise pre-inserts v2_job, sets `started_at` to NOW() - 1s when running=true (so running rows contribute ~1 worker-second by the time the refresh runs), and seeds v2_job_runtime.ping so the running side accrues real-time worker seconds (the algorithm bounds end-of-interval by ping). The zombie/suspended insert helpers are intentionally left without v2_job rows — the new algorithm's INNER JOIN excludes them, so they still correctly contribute zero worker-seconds. * chore(queue): bump EE ref to duration-weighted fairness algorithm Companion to windmill-ee-private#<TBD>: switch the EE workspace fairness aggregation from a count-based UNION (worker_ping snapshot + v2_job_completed count) to a worker-seconds aggregation sourced directly from v2_job_queue and v2_job_completed, with kind/suspend filters mirroring handle_zombie_jobs and per-row defenses against zombie inflation on both halves. * chore(queue): bump EE ref for fairness perf fix (inline window_start) * chore(queue): bump EE ref for fairness perf rewrite (driver-side flip) * update ee ref * feat(hub-publish): add backend proxy routes for hub publishing New workspaced router /api/w/:ws/hub/* forwarding to the Hub: - POST /publish_draft → POST {HUB}/workspaces (slug/name/summary/readme) - POST /scripts → POST {HUB}/scripts/add (workspace_slug + content) - POST /flows | /apps | /raw_apps → corresponding hub endpoints - POST /scripts/:ask_id/recording, /flows/:flow_id/recording → recording uploads Auth uses HUB_DEV_TOKEN env var (dev shortcut). All bodies are serde-typed; the helper forward_to_hub centralises the HTTP call. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(deploy-to-hub): wire frontend to backend hub proxy Replaces the mocked deploy flow with real backend calls: - confirmBundle() POSTs /hub/publish_draft with sanitized slug, name, summary and readme. - deployAll() pushes selectedItems one by one via pushItem(), fetching the live content (Script/Flow/AppService + raw_apps get_data) before forwarding to /hub/{scripts,flows,apps,raw_apps}. - saveRecording() builds the replay-shaped payload expected by the Hub (initial_job + events with type: 'CompletedJob') and POSTs to /hub/{scripts,flows}/{hub_id}/recording. - Adds bundleSummary state + TextInput in the drawer. Hub item ids (ask_id / flow_id) returned by the create calls are cached client-side to wire later recording uploads. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(hub-publish): add /resources proxy route Forward workspace resource stubs (path + type) to the hub's /workspaces/{slug}/resources endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(deploy-to-hub): auto-detect resource dependencies from selection Derive resource dependencies from the $res:/res:// references in the selected scripts/flows/apps instead of a manual resource list, sync them as empty stubs, and show them read-only (chip per type, hover for path + which items use it). Aborts item publish if dependency sync fails to avoid broken fork references. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(deploy-to-hub): add project-bundle closure + path-rewrite logic Pure, unit-tested module (projectBundle.ts) backing the "project = folder" Hub bundle: - extractScriptRefs / extractFlowRefs / extractAppRefs: structural detection of $res: references (code, static step inputs, script-by-path), hub refs classified separately. - classifyPath / buildPathMap: relocate external u/.. and f/other/.. paths under f/<slug>/, with deterministic _2/_3 collision suffixes. - rewriteContent / rewriteFlowValue / rewriteAppValue: rewrite every ref to its relocated path, leaving hub/.. untouched. - buildProjectBundle: walk the transitive closure of a seed selection (scripts pulled in recursively, resources pulled as stubs), returning the rewritten items + resource stubs + unresolved list. 14 vitest cases cover classification, extraction, collision suffixing, partial-match safety, deep-clone, and the closure orchestrator. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(deploy-to-hub): publish as relocated project bundle + resource drawer - deployAll now builds a self-contained project bundle (buildProjectBundle), pushing resource types, empty resource stubs at relocated f/<slug>/ paths, and the rewritten items — so a fork's references resolve inside the project. - Resource-dependency detection is unified on the same bundle: the UI list (dependencyTypes) is derived from the bundle preview, guaranteeing what's shown matches what's pushed. Removes the duplicate in-component detection (extractResRefs/refsForItem/resolveResourceSet/typeForResource). - Input-type deps (schema format: resource-<type>) are synced as types and conventional f/<slug>/<type> stubs alongside hardcoded ones. - Replaces the hardcoded-path warning/fix/block machinery with a read-only "Resource dependencies" drawer: per-type usages tagged input vs hardcoded path, with an info popover explaining the portability tradeoff. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(deploy-to-hub): gate hub publish endpoints + harden trigger detection - Add ApiAuthed + require_admin to all hub publish handlers; previously any workspace-authenticated session could trigger Hub-side writes attributed to the URL workspace via the shared HUB_DEV_TOKEN. - Track per-kind trigger fetch failures (triggerLoadErrors) so an EE-gated or transiently failing trigger service no longer silently maps to "0 triggers"; UI surfaces an amber badge listing the missing kinds and a toast warns the operator before publish. - Add workspaceLoadSeq cancellation so the parallel loadWorkspace + loadTriggers stop bleeding stale data when the workspace switches mid load. - Drop the silent effectiveSlug fallback to sanitizeSlug(hubName) when the Hub response can't be parsed; abort the publish instead so items don't land under a slug the Hub never locked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(deploy-to-hub): thin /triggers proxy to forward trigger bulk-sync to Hub Mirrors the existing /scripts, /flows, /apps thin proxies. Forwards { triggers, workspace_slug } to Hub's POST /workspaces/[slug]/triggers bulk-replace endpoint, with the same require_admin + HUB_DEV_TOKEN guardrails. Lets the frontend push trigger stubs in a single round-trip after the items they reference have landed on the Hub. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(deploy-to-hub): push trigger stubs as the final bundle step After scripts/flows/apps land on the Hub, pushTriggers() builds a relocation map for the trigger paths, strips operational metadata (workspace_id, edited_by/at, enabled, last_*/captured_*, capture data, error_handler_path/args, permissioned_as*) from each config, resolves script_ask_id / flow_id via the hubItemIds map produced by step 3, and POSTs the whole set to /api/w/:wsp/hub/triggers. Triggers whose runnable didn't publish are skipped with a warning rather than emitted as broken stubs. Also drops the per-kind trigger-load error surfacing: feature-gated services (Kafka, NATS, ...) 404 on instances that don't enable them, and the banner was lighting up on every load for nothing. Errors are swallowed silently again, matching the pre-review behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(hub_publish): rename Hub-facing fields and URLs from workspace to project Matches the windmillhub rename: every body now carries `project_slug` instead of `workspace_slug`, the draft creation forwards to `/projects`, and the resource_types/resources/triggers proxies hit `/projects/{slug}/...`. `HubWorkspaceBody` becomes `HubProjectBody`. The instance-side `Path(workspace)` extractor and the `workspace` URL parameter stay because that's still the source tenant's identifier. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ui(deploy-to-hub): user-facing rename from "workspace" to "project" The Hub-deploy surface now talks about *projects* (the bundle published to the Hub) instead of *workspaces* (which still means the source tenant). Tab is "Publish project", header copy mentions "project", the Hub URL in the breadcrumb points to /projects/<slug>, payload field is `project_slug`. Internal state names (`workspaceItems`, `workspaceStore`, `WorkspaceService`, …) stay — they refer to the instance workspace the items are read from, which has not been renamed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ui(deploy-to-hub): open-in-tab affordance on each dependency and trigger Adds a small ExternalLink icon at the far right of every row in the Resource dependencies drawer (script / flow / app / raw_app) and the Triggers drawer (per trigger kind, opens the matching list page — /routes, /schedules, /websocket_triggers, /kafka_triggers, …). Both buttons open in a new tab scoped to the current $workspaceStore. Sized to sit after the role badge so the dominant signal (input vs hardcoded path, script vs flow) stays read first. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(deploy-to-hub): proxy raw app embed to the Hub Add POST /w/{workspace}/hub/raw_apps/{id}/embed forwarding to the Hub so a shared (public) raw app's external_embed_url can be set/cleared. null is forwarded (not skipped) so unpublish clears the embed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(deploy-to-hub): bundle raw apps, share live iframe, folder-scoped bundles - Detect modern raw apps (app table, raw_app=true) and push them to the Hub as raw apps: fetch source files + runnables + the compiled bundle (via the latest-version bundle secret) and shape them into the raw payload RawAppView expects. Fail loudly when no compiled bundle exists. - Capture the Hub id for raw apps and wire "Share as iframe"/"Unpublish" for them (post-bundle, like recordings); re-sync the embed on re-bundle for already-public apps. Factor the publish/unpublish flow into setAppShared + pushRawAppEmbed helpers. - Scope bundles to a single required f/<folder>/ (Select instead of MultiSelect) so relocated paths stay predictable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(hub): send item path when publishing a project to the hub Include each item's newPath in the script/flow/app/raw_app publish payloads so the hub can store the relocated Windmill path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(hub): accept path on publish and proxy project export Add an optional path field to the publish bodies and a GET /projects/{slug}/export route that proxies the hub export (admin-only, authenticated with HUB_DEV_TOKEN). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(projects): add project install page New /projects/install page pulls a hub project's export and re-creates it in the selected workspace. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(deploy-to-hub): let user pick target folder on project import Add a FolderPicker to the project install page (defaulting to the project slug, with create-new-folder support) and retarget every `f/<slug>/` prefix in the bundle — item paths, $res:/script refs, schedule runnable paths — to the chosen folder in one pass. Ensures the target folder exists before creating items. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix wording * fix(hub-publish): bind Hub publish/export to the trusted workspace via source_id Hub publish endpoints ignored the {workspace} path and addressed the Hub project purely by client-supplied project_slug, forwarding with an instance-wide HUB_DEV_TOKEN. Any workspace admin could mutate or export another workspace's Hub project by passing its slug. Stamp the server-trusted workspace from the path onto every forwarded request as source_id (body for mutations, query param for export) so the Hub can enforce that the targeted project belongs to the calling workspace. Requires the matching Hub-side source_id ownership check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deploy-to-hub): reset draft/publish state on workspace switch The workspace-switch effect only reset load-derived state, so phase, draftItems, recordings, hub/bundle metadata, hubVersion, deploymentStatus, effectiveSlug and hubItemIds survived a switch — a draft built in one workspace could publish its items/slug under the next workspace's auth. Reset the full publish session on switch. Also drop explanatory comments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deploy-to-hub): pull sub-flows referenced by type: flow steps into the bundle extractFlowRefs only emitted refs for type: script steps, so a flow calling an external sub-flow by path was never followed and the published project was silently incomplete. Add a 'flow' RefKind, emit it for type: flow steps, recurse on it in buildProjectBundle, and rewrite its path on relocation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deploy-to-hub): fall back to slug when import folder is whitespace-only (folderName || slug).trim() let a whitespace-only folder bypass the slug fallback and trim to an empty target, producing invalid f//... paths and a failed import. Trim first, then fall back. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hub-publish): validate project slug before interpolating into Hub path slug/project_slug are caller-controlled and were interpolated straight into the Hub request path; a crafted value (e.g. ../../admin) could reach an unintended Hub endpoint after URL normalization. Validate against the frontend charset (lowercase alphanumerics + hyphens, 3-50 chars) in the four handlers that put the slug in the path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deploy-to-hub): use ?tab query param to link to the Apps settings tab The "Edit in Workspace settings → Apps" link set window.location.hash, but the settings page derives the active tab from ?tab=..., so the link was a dead affordance. Navigate with goto('?tab=default_app') instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deploy-to-hub): point Open in Hub link at the project slug, not the workspace hubSlug was derived from $workspaceStore, so the Open in Hub link and badge used the workspace id instead of the published project slug — navigating to the wrong (or nonexistent) Hub project. Derive hubSlug from the actual project slug (effectiveSlug, falling back to sanitizeSlug(hubName)). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Sync hub to instance * feat(deploy-to-hub): rehydrate project state, wire review flow, bundle trigger resources - Rehydrate the publish panel from the Hub by source_id on load (phase, slug, metadata, items, hub ids, recordings) so refresh no longer loses the draft. - Map Hub project status to the draft/under_review/live phase; submitForReview now persists to the Hub instead of a local stub; drop the unused v{n} version display (status is the source of truth). - Send source_path (original workspace path) per item for recording round-trip. - Detect resources referenced by triggers, add them to the bundle closure (extraResourcePaths) so they appear in dependencies, get stubbed/relocated, and rewrite the trigger config path via the full bundle pathMap (no leaked private path); show trigger usages in the dependency drawer. - Review fixes: Array.isArray guards on trigger topic/subject lists; snapshot relevantTriggers in deployAll to avoid a mid-deploy folder-switch race; index-key the usage list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deploy-to-hub): keep item summary on rehydrated draft, drop placeholder diff button Rehydrated draft items now carry their summary (from the Hub) so step 2 shows the summary like step 1 instead of falling back to the path. Remove the "Diff vs submitted" button: it only toasted add/remove counts with no view, which read as broken. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deploy-to-hub): New draft returns to the folder-picker step instead of erroring In the live phase the folder picker is hidden, so startNewDraft's selectedFolder guard always failed with "Pick a folder..." and the user had no way to pick one. Now New draft goes back to step 1 (predeploy) with the project's folder pre-selected (inferred from the item paths) so the user can re-bundle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hub_publish): return 500 not 400 when HUB_DEV_TOKEN missing * fix(deploy-to-hub): keep internal subfolder paths identity-mapped when bundling * fix(projects-install): never overwrite existing resources; isolate invalid raw app json * fix(deploy-to-hub): route raw_app to apps_raw/get and guard openRecord schema race * refactor(hub_publish): extract hub_token helper, drop duplicated env lookup * refactor(projects-install): route raw-app and unsupported-trigger failures through record() * fix(deploy-to-hub): refresh review status from Hub and use configured hub base url * fix(hub_publish): return 400 not 500 when HUB_DEV_TOKEN is unset Missing config is a client/config error, not a server fault. Restores the BadRequest class lost when hub_token() was extracted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(hub_publish): scope Hub projects per folder via workspace:folder source key * feat(deploy-to-hub): publish per-folder projects from the Folders page * ui(deploy-to-hub): move phase CTA to the top-right header * Fable review * feat(hub_publish): forward the caller's token to the Hub instead of HUB_DEV_TOKEN * style(windmill-api): cargo fmt fallout in build.rs and lib.rs * Nit fixes * Nit fix * fix: structural project-ref rewrite and deploy-to-hub state fixes Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: deterministic draft phase fallback when post-deploy rehydrate fails Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): skip EE-only native trigger calls on CE to avoid console 404s Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(deploy-to-hub): fork all trigger kinds on project install The project install (fork) flow recreated only schedule triggers and rejected every other kind with "not supported yet". Recreate all trigger kinds instead, imported disabled (enabled: false → mode disabled). Kafka, NATS, SQS, GCP and Azure require an Enterprise license, so they are gated behind enterpriseLicense and reported as "requires Enterprise" on CE rather than firing backend calls that 404. http, websocket, postgres, mqtt and email are recreated on CE. The kind-specific config (with retargeted resource paths) is spread into the create body; explicit path/script_path/ is_flow/summary/enabled win over it. Also carry the schedule summary through. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deploy-to-hub): list CE trigger kinds without an Enterprise license loadTriggers wrapped http, websocket, postgres, mqtt and email list calls in eeList, so on CE (no enterpriseLicense) they resolved to [] and never made it into deploy state — those triggers silently disappeared from the Hub publish set. Only Kafka, NATS, SQS, GCP and Azure are EE; switch the CE kinds back to safeList so they are always listed and published. Mirrors the EE gating used on the project install (fork) side. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deploy-to-hub): reload triggers when EE license hydrates late loadTriggers captures enterpriseLicense at call time and the main reload $effect only depends on workspace/folder, guarded by lastLoadedKey. When the license store hydrates asynchronously after loadTriggers already ran, the EE trigger kinds (kafka/nats/sqs/gcp/azure) stay empty until the workspace or folder changes. Add a dedicated $effect that re-fetches triggers on the license false→true transition, mirroring the sidebar's license-race handling. prevHadLicense is seeded from the current value so a license already present at mount doesn't trigger a redundant reload. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deploy-to-hub): token loadTriggers so a late EE reload can't be clobbered The license-late reload calls loadTriggers with the same workspaceLoadSeq as the original license-less load, so the workspace guard alone lets both assign workspaceTriggers. If the earlier (EE-empty) request resolves last, it overwrites the newer license-aware result and the EE trigger kinds disappear again. Add a per-invocation triggerLoadSeq token and only let the latest load assign (and toggle triggersLoading), so a slow earlier request is discarded. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deploy-to-hub): use mode 'disabled' for forked non-schedule triggers Non-schedule triggers expose `mode` (TriggerMode), not the deprecated `enabled` flag, in their create body. `enabled: false` happens to still map to disabled today via the backend's legacy BaseTriggerData field, but relying on a deprecated path is fragile. Set `mode: 'disabled'` explicitly so imported http/websocket/postgres/mqtt/native triggers stay disabled. Schedules keep `enabled: false` (NewSchedule uses the enabled flag). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deploy-to-hub): snapshot workspace + key bundle by kind:path Address three P1 review findings: - Project install (fork) read the reactive `workspace` ($derived) across many sequential awaits, so a workspace switch mid-import could create the folder in one workspace and later items in another. Snapshot the target workspace once at the top of install(). - DeployToHub.deployAll re-read $workspaceStore after confirmBundle had already created the Hub draft bound to a specific workspace's source_id, so a switch during draft creation could publish items to a different workspace. Pass the workspace captured by confirmBundle into deployAll instead. - buildProjectBundle keyed its fetched/queued maps by bare path, silently dropping one of two distinct-kind items at the same path (script vs flow). Key by `${kind}:${path}` and derive item paths from the fetched values, keeping path relocation separate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(deploy-to-hub): condense comments Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deploy-to-hub): surface backend error body on failed project import record() only showed `e.message`, which for API errors is the generic status text ("Bad Request"). Prefer the ApiError `.body` (plain-text reason for Windmill 4xx) so a failed import reports the actual cause — e.g. a path or route_path collision — instead of a bare "Bad Request". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deploy-to-hub): close mid-request workspace-switch races Two follow-ups to the workspace snapshotting: - confirmBundle captured `workspace` but read selectedItems / relevantTriggers / hubSlug only inside deployAll, after the publish_draft await. A workspace switch during that request resets those to the new workspace, so deployAll would push the new workspace's items into the old workspace's Hub draft. Capture workspaceLoadSeq before the request and abort (with a toast) if it changed before publishing. - install() snapshotted `workspace` but still read the reactive `data` after the createFolder await; load() can replace `data` on a workspace switch, so retarget() could run against a different export than `folder` was derived from. Snapshot `data` up-front and use it throughout install(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deploy-to-hub): guard stale load response and mid-publish status writes - install load() assigned `data`/`folderName` unconditionally, so a slow /export for an old ?hub= could overwrite a newer project after navigation. Add a load token + captured slug/workspace and only assign if still current. - deployAll wrote deploymentStatus/hubItemIds incrementally and only checked the workspace at the very end. Bail at the top of the per-item loop when the active workspace changed, so a mid-publish switch can't keep writing the old workspace's item statuses and Hub IDs into the new workspace's live view. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(hub-projects): generate and apply datatable migrations on project publish/install (#9977) * feat: add datatable_migrations table * feat: add route to run datatable migrations * feat: sync datatable migrations as .up.sql/.down.sql files * feat: add datatable migrate up/down commands and post-push run prompt * feat: add datatable migrate new command to scaffold migrations * feat: add datatable migrations management UI * feat: prompt to create migration on DDL in datatable SQL editors * feat: support running a single specific datatable migration * feat: view migration content, run single migration, fix stacked modal * feat: per-row revert button with out-of-order warning * fix: avoid migrations list flicker on refresh after an action * feat: generate initial datatable migration via pg_dump * fix: surface datatable migration API error details in toasts * fix: revert created migration if create-and-run fails to run * fix: include postgres error detail in migration run/rollback failures * feat: sync datatable migrations as files via the workspace export * refactor: move datatable migrations to migrations/datatable/ path * fix: drop redundant datatable_migration label in sync output * fix: exclude datatable migration sql files from script metadata generation * feat: run datatable migrations as user-permissioned labeled jobs * feat: reject invalid datatable migrations on sync push * feat: datatable migrate up/down default to all datatables, --datatable to target one * fix: surface postgres error detail when datatable migrations fail to run * chore: regenerate CLI docs for datatable migrate commands * feat: default new datatable migration to a BEGIN/END transaction template * fix: validate datatable migration name and datatable at the API boundary * fix: ensure detected DDL ends with semicolon when wrapped in transaction * fix: re-prompt instead of stripping DDL when new-migration modal is cancelled * feat: refresh datatable schema after running a migration from the SQL REPL * feat: record db manager DDL on data tables as migrations * feat: make datatable migrations opt-in per data table * fix: make migration view editor read-only so its code can scroll * fix: don't re-prompt DDL guard when creating a migration without running * feat: generate down migrations for db manager DDL (postgres) * fix: correct down migration for db manager alters (no double-wrap, serial) * feat: explain migrations purpose with a tooltip in the migrations modal * compare paeg * feat: add datatable_migration kind to workspace diff pipeline * chore: point ee-repo-ref at datatable_migration git-sync companion * fix: harden datatable migration version allocation and initial-migration bookkeeping, add tests * feat: deploy and run datatable migrations on workspace merge Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Refactor + handle datatable setting delete/rename * refactor: move datatable migration rename/delete cascade into module Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(windmill-utils-internal): bump to 1.7.1 for datatable migration deploy provider methods Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(db-manager): add Migrations button to top bar, make Refresh icon-only Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * BEGIN/END placeholder in down migration * feat: autofocus migration name input and flag it red when empty Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(datatable-migrations): allow non-admins to create/run/revert migrations, gate only opt in/out Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * border nits * refresh db manager schema on migrations * BEGIN/END scaffold in CLI * feat(cli): push local datatable migrations before running on migrate up Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: flag invalid migration name with red border, not just empty Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: drop random slug from auto-generated migration names Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: offer revert-and-delete when deleting an installed migration Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: record fork merge as a migration when target datatable opts in * nit * clone migrations on fork * windmill-utils-internal * fix(datatable-migrations): serialize run/rollback with a per-db advisory lock Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(db-manager): fail closed when migrations-status check errors on DDL apply Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: fix generate_initial migration ordering comment to match code * chore(datatable-migrations): remove unused update_datatable_migrations endpoint Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: run DDL migration guard on the script editor Test button Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * split * ee-repo-ref * chore(frontend): sync package-lock with package.json (@emnapi deps) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(datatable-migrations): never resolve instance credentials into migration job args datatable_database_arg eagerly resolved instance data-table credentials (including the shared instance-wide Postgres password) and passed them as the migration job's plaintext `database` arg, landing in v2_job.args. Since the run route has no admin gate, a non-admin could run a migration and read args.database to recover the password, granting cross-workspace psql access to all instance data-table DBs. Pass a `datatable://<name>` reference for both resource-backed and instance data tables instead; the pg executor already resolves it to real credentials server-side at run time, so nothing sensitive is ever stored in the job args. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * nit * fix: handle dollar-quoting and comments when splitting SQL statements * feat: deploy datatable migrations on merge with explicit opt-in error * fix(frontend): sync package-lock with npm 11 peer-dep resolution npm ci failed with 'Missing: @emnapi/core@1.11.2 / @emnapi/runtime@1.11.2 from lock file'. @napi-rs/wasm-runtime declares @emnapi/core|runtime ^1.7.1 as peerDependencies while @rolldown/binding-wasm32-wasi pins them to exactly 1.10.0. Newer npm (bundled with node 24 in CI) installs the peer deps at the highest match (1.11.2) alongside rolldown's nested 1.10.0, so the ideal tree needs both versions; the committed lock only had 1.10.0. Regenerate the lock with npm 11.18 so it carries both 1.11.2 (top-level, for the peer deps) and 1.10.0 (nested, for rolldown's pin). Verified npm ci passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * nit npm publish * fix: fail closed on migrations-status error in fork schema merge * nit CI emnapi/core version * prevent initial_datatable_migration if migrations already exist * fix(datatable-migrations): validate persisted data table names as path segments edit_datatable_config only validated rename segments, not the actual settings.datatables keys, so a data table could be saved directly under a name like '..' or one containing '/'. Since new tables default to migrations_enabled = true, generate_initial_datatable_migration would then insert a migration row and the sync export would build migrations/datatable/<name>/... paths from that name, producing malformed or directory-escaping export paths. Validate every persisted data table name in edit_datatable_config (alongside the existing rename checks) and add validate_datatable_path_segment to generate_initial_datatable_migration for defense in depth. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: scope datatable _wm_migrations by data table and cascade renames/deletes Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(system_prompts): resolve nested local command groups in CLI docs generator The CLI docs generator anchored on the first `new Command()` in a file and never resolved locally-defined command groups passed as `.command("name", localCmd)`. For datatable this flattened the nested `migrate` group: it emitted `datatable new/up/down` plus a bare `datatable migrate`, and mislabeled the datatable command with the migrate group's description. jobs was broken the same way (its description was pull's, and pull/push rendered empty). Anchor block extraction on the `export default`ed command, recurse into locally-defined `const x = new Command()` groups mounted as subcommands, and render nested sub-subcommands. Regenerated docs now show `datatable migrate new/up/down` and `jobs pull/push` with their real options. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: drop unreleased _wm_migrations legacy-upgrade handling Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: return datatable migration SQL from getItemValue for the diff drawer Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(frontend): use windmill-utils-internal 1.8.2 for migration diff drawer Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * nit * nit * fix: handle datatable migration renames on push and dedupe timestamps * fix: reject rewriting an already-applied datatable migration on upsert Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): add missing @emnapi/core and @emnapi/runtime lockfile entries Resolves npm ci EUSAGE failure: the optional cpu:wasm32 @rolldown/binding-wasm32-wasi declares deps on @emnapi/core@1.11.2 and @emnapi/runtime@1.11.2 that had no resolved lockfile entries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): datatable migrate up/down default to main datatable, not all Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: fail closed when applied status unreadable on datatable migration rewrite Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: surface full error detail in Database Manager DDL/query errors * "See migration" button in the toast * feat: add Enter shortcut to Create-a-migration in the DDL guard * fix(frontend): warn before running a newly-created datatable migration out of order The row-level Run action warns when earlier migrations are still pending, but the create-and-run paths ran a just-created migration with `only` directly, applying it ahead of older pending migrations without that confirmation. Reuse the same "Run migration out of order" confirmation across all create-and-run paths via a shared helper (datatableMigrationUtils): - NewDataTableMigrationModal "Create and run" (and the DDL guard path) - DatatableSchemaDiff fork→parent merge - dbOps schema ops (DB manager create/alter/drop) — the pure factory throws a MigrationRunCancelled sentinel on decline, which DBTableEditor treats as a silent cancel Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: keep renamed datatable migrations visible in compare view * fix: record per-migration deployment on datatable migrations disable * fix(cli): run deployed datatable migrations after workspace merge The merge command upserted datatable_migration definitions into the target workspace and reported the item as successfully deployed, but never ran the migrations. For forked datatables backed by separate databases, this left the target schema unchanged until someone manually ran `wmill datatable migrate up`, while the CLI reported a successful merge. Collect the datatable migrations deployed (not deleted) into the target and, after the deploy loop, offer to run them via the existing offerToRunNewMigrations helper — the same post-deploy run prompt the push/sync path uses (interactive only; `--yes`/non-TTY skip the mutating run, matching push behavior). Export parseDatatableMigrationDeployPath so the merge path can parse the deployed items. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(backend): serialize datatable migration edits/deletes with the run lock A migration run snapshots a migration's code_up from datatable_migrations and only records its version in the data table's _wm_migrations after the job succeeds. upsert_datatable_migration checked _wm_migrations before allowing an edit but took no lock, so a concurrent edit could read "not applied yet", rewrite code_up/code_down, and then the in-flight run would record the version for the old SQL — leaving _wm_migrations pointing at SQL that was never applied (migrate up then skips it; rollback runs a down that doesn't match). Serialize definition rewrites and deletes with the same per-database advisory lock the run/rollback paths use: - Factor the connect+advisory-lock into lock_datatable_migration_runs and the applied-versions read into read_applied_versions_on_client. - run_datatable_migrations now snapshots the definitions AFTER taking the lock, so code_up can't change between snapshot and version-record. - upsert (when changing an existing def) and delete take the lock across the applied-check and the write; delete now rejects deleting an already-applied migration (would orphan its _wm_migrations record), symmetric with upsert. Both fail closed if the data table database is unreachable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): stack the out-of-order migration confirm above the DB editor preview Creating a table on a migrations-enabled data table opened the DB table editor's "Confirm running the following" preview modal, whose confirm triggers applyDdl, which then asks for out-of-order confirmation. Both are ConfirmationModals with a hardcoded z-[9999]; the out-of-order one lives in DBManagerContent (mounted before the editor), so it rendered behind the still-open preview modal. Add an optional zIndexClass prop to ConfirmationModal (default z-[9999], backward-compatible) and give the DB-manager out-of-order confirm z-[10000] so it stacks on top. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(hub-projects): generate and apply datatable migrations for projects Detect datatable assets in a project's scripts/flows/raw apps when publishing to the Hub, generate a best-effort CREATE TABLE migration per data table from the source workspace's live schema, and let the publisher edit/toggle them in the bundle drawer. On import, offer to run the shipped migrations: recorded (datatable_migrations + _wm_migrations) when the target data table opted into migrations, otherwise as a one-off preview job. Missing target data tables are surfaced and skipped. - backend: POST /hub/migrations proxy forwarding to the Hub - frontend publish: projectMigrations.ts detection + generation, new "Data table migrations" section in DeployToHub - frontend import: run/skip modal + missing-datatable confirmation - extract pure SQL-gen from DatatableSchemaDiff.svelte into datatableSchemaSql.ts so plain .ts modules can import it Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(hub-projects): close datatable migration table set over foreign keys Pull a referenced table's FK targets into the generated migration transitively, so it creates every table it references (ordered by FK dependency), and drop any FK whose target still isn't in the set so the generated SQL always runs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(hub-projects): show Data table dependencies in the publish view Detect data table usage off the predeploy bundle preview and surface it as a "Data table dependencies" summary right after "Resource dependencies", mirroring how resource types and triggers are shown. The editable migration itself stays in the bundle drawer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(hub-projects): explain un-generated migrations with SQL comments When a table can't be found in the schema, a data table is referenced as a whole, or the schema can't be loaded, write a `--` comment describing the problem into the migration instead of leaving it blank. Partial migrations keep the CREATE TABLEs that did generate and comment the rest; comment-only migrations stay disabled. The bundle drawer now always shows the SQL box so those comments are visible and editable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(hub-projects): review/edit migrations on import + rollback down migration Replace the plain "run migrations?" confirmation with a review drawer that previews each runnable migration, lets the user edit the SQL and toggle which to run, before the import proceeds. When recording an imported migration, also record a down migration (DROP TABLE of the created tables, in reverse order) derived from the up SQL, so it can be rolled back; the derived rollback is previewed in both the publish bundle drawer and the import review drawer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * nit * feat(hub-projects): editable Up/Down Monaco editor for migrations Replace the plain textarea with a Monaco SQL editor split into Up/Down tabs. The down migration is now generated once as best-effort (DROP TABLE in reverse creation order) and is fully editable — no longer parsed back out of the up SQL. The down is threaded through publish → Hub → import (new project_migration.sql_down) and recorded as code_down when an imported migration is applied. - projectMigrations: GeneratedMigration.sql_down generated from the table set - MigrationSqlEditor.svelte: shared Up/Down tabbed Monaco editor (re-keyed on regeneration since Monaco ignores external code changes) - DeployToHub + install review drawer use it; sql_down pushed/applied - backend: PublishMigrationBody carries sql_down Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hub-projects): generate CREATE TABLE IF NOT EXISTS for project migrations The FK closure pulls a referenced table's parents into the same transaction (e.g. `orders` drags in `customers`); those shared parents often already exist in the target, so a plain CREATE TABLE aborted the whole migration on the first collision. Emit CREATE TABLE IF NOT EXISTS for project migrations (via a new opt-in flag on generateMigrationSql, leaving the schema-diff behavior unchanged) so a pre-existing parent is skipped instead of failing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hub-projects): key FK ordering by schema-qualified table name orderByFkDependency keyed its dependency graph by bare table name (and resolved FK targets with .split('.').pop()), so two same-named tables in different schemas collapsed and one was dropped from the ordered set and never created. Key by schema.table like the rest of the pipeline, resolving FK targets through resolveTable. Also let resolveTable fall back to the bare table name when a schema-qualified ref's schema doesn't match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hub-projects): comment out generated down-migration DROP statements The generated down migration listed DROP TABLE for every table in the FK closure, including shared parent tables that may have pre-existed in the target — a rollback could drop a table the project never created (data loss). Emit all DROP statements commented out with a note, so nothing is dropped by default; the publisher uncomments the tables this migration actually owns. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hub-projects): disable Import button during migration review planMigrations awaits the review / missing-datatable modals before setting installing = true, so the Import button stayed enabled during review and a second click launched a concurrent install() (second review drawer, duplicated item creation). Track a planningMigrations flag, disable the button on it, and early-return install() if already installing or planning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hub-projects): toast when migration generation fails regenerateMigrations cleared the drafts on error, showing "No data table usage detected" — indistinguishable from a genuine schema-load failure. Add a toast on the catch so the publisher can tell the two apart. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hub-projects): honor cancel on the missing-data-table warning planMigrations awaited missingDatatableModal.ask() but ignored its boolean, so cancelling the "some data tables are missing" warning still proceeded with the import — the cancel affordance did nothing. Show the warning first and abort the whole import when the user cancels (planMigrations returns null; install() early-returns), so they can create the data table(s) and re-run. Confirming still imports without the missing migrations. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(hub-projects): detect data tables from low-code app DB-table config Low-code apps don't carry a persisted asset list, but the DB-table component declares its data table and table explicitly: a `oneOf` `type` config with `selected === 'datatable'` holding `datatable://<name>` and the table. Walk the app value for those configs so an app that reads a data table is picked up by the Data table dependencies detection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Revert "feat(hub-projects): detect data tables from low-code app DB-table config" This reverts commit9c43ebd512. * fix(hub-projects): detect data tables from full-code apps' declaration Full-code (raw) apps explicitly declare the data tables/tables they use in value.data.tables (refs like main/customers or main/schema:table), which the "Data table dependencies" detection missed — it only looked at inline-script assets. Read the declaration via extractDataConfig/parseDataTableRef. The bundler previously dropped value.data (kept only files + runnables); include it so detection sees it and the imported app keeps its declaration, and pass it through on import. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hub-projects): recompute app policy on project import Apps imported from a Hub project were created with an empty triggerables_v2 policy, so running any inline component script failed at runtime with "Path rawscript/<sha> forbidden by policy". The policy is computed client-side on deploy and stored verbatim by the backend, and import skipped that step; retargeting also rewrites inline-script content (changing its sha), so a copied policy would not match either. Recompute the policy from the retargeted value at import, mirroring the deploy path: updatePolicy for grid apps, updateRawAppPolicy for raw apps, defaulting execution_mode to publisher. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * nit fix --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hub-projects): retarget plain trigger resource paths on import Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(hub-projects): reset migration drafts on workspace/folder switch Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(hub-projects): bundle http auth resources, pin drafts during deploy Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(hub-projects): make generated data table migrations idempotent Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Reapply "feat(hub-projects): detect data tables from low-code app DB-table config" This reverts commit112844deea. * fix(hub-projects): create all tables before FK constraints in migrations Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(hub-projects): reset install state when the hub slug or workspace changes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Revert "Reapply "feat(hub-projects): detect data tables from low-code app DB-table config"" This reverts commit14abefb4f6. * fix: dedupe args state duplicated by main merge in AssetGraphDetailsPane Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(deploy-to-hub): extract session class keyed by workspace+folder All DeployToHub state and async operations move into DeployToHubSession (deployToHubSession.svelte.ts), an immutable-(workspace, folder) state class. A workspace/folder change replaces the instance and remounts the UI via {#key} instead of manually resetting ~20 state vars, and in-flight async work writes to the discarded object instead of racing the new scope. The workspace-scoped seq counters (workspaceLoadSeq/triggerLoadSeq for lifecycle, migrationsSeq) collapse into a dispose flag plus intra-session tokens only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqqWYQR46tcunvPVRfidZS * refactor(triggers): single shared module for all-kind workspace trigger listing TRIGGER_KINDS (badge/route/note/resourceField/eeOnly + list call), listAllWorkspaceTriggers, triggerResourcePath, stripTriggerConfig and triggerDetails move to $lib/components/triggers/workspaceTriggersList.ts, so EE-license gating per trigger kind is declared once instead of being re-decided at each call site. DeployToHubSession consumes it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqqWYQR46tcunvPVRfidZS * refactor(hub-publish): route every endpoint through one validation choke point HubPublishCtx (a FromRequestParts extractor) is now the only way a handler reaches the Hub: it performs the admin check, resolves and validates the workspace:folder source key, and carries the forwarded token — a new endpoint cannot skip any of it. Project slugs become a ProjectSlug newtype whose only constructor is validating deserialization (body field or path segment), so every slug that reaches a Hub URL or payload is valid by construction; the previously unvalidated slugs in publish_draft/scripts/flows/apps/raw_apps/ embed/recording bodies are now checked too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6 * refactor(hub-projects): shared bundle format module + per-kind project installer The Hub export format (types + retargetProjectExport/buildRetargetMap) moves into projectBundle.ts so publish and install share one definition, with unit tests for retargeting. projectInstall.ts owns the import: one importer per item kind with per-item error capture, and trigger creation goes through createWorkspaceTriggerDisabled in the shared trigger module, which encodes the per-kind disable semantics (schedules use enabled:false, everything else mode:'disabled') and EE gating once. The install page shrinks to orchestration and UI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6 * fix(hub-projects): AMQP kind, trigger handler bundling, import containment Review-round fixes: register the AMQP trigger kind (CE) in the shared registry so it lists/bundles/imports like every other kind; stop stripping error_handler_path/args from trigger configs and bundle + relocate handler runnables (including schedules' script|flow-prefixed on_* refs) with the project; resolve full schedule rows on listing (listSchedules is slim) and spread the exported config on import so cron_version, retry, handlers and no_flow_overlap survive; refuse per-item any export path that escapes the selected f/<folder>/ target; and gate the install page's results/done writes on the load sequence so a stale import can't mark a newly loaded project as imported. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6 * fix(hub-projects): schedule config hygiene and complete handler bundling Strip email/is_draft/paused_until from exported trigger configs (the full schedule row carries owner and runtime state that must not reach the Hub); bundle and relocate dynamic_skip handler scripts (schedule creation refuses a missing one, so an unrelocated path breaks the import); exclude and report a schedule whose detail fetch fails instead of silently exporting the slim row with default behavior; and seed migration detection with the same handler-augmented item set as deployment so data tables used only by bundled handlers get their migrations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6 * fix(deploy-to-hub): single guarded publish path, gated on trigger load publishBundle() owns draft creation + deployment under one synchronously-set deploying flag, so a double-click can't start two interleaved publishes, and it refuses to run while triggers are still loading — snapshotting an incomplete relevantTriggers list would permanently omit triggers, their handlers and handler-only migrations from the draft. The bundle CTAs disable while trigger discovery is in flight. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6 * fix(hub-projects): block publish on failed trigger discovery, strict schema-qualified table resolution listAllWorkspaceTriggers now distinguishes a feature-gated 404 (kind not compiled into the instance — legitimately empty) from a real listing or detail-fetch failure: failures are surfaced, recorded per kind, and the session blocks publishing with a visible retry until discovery completes cleanly, so an incomplete trigger snapshot can't be bundled silently. resolveTable no longer falls back to a same-named table in another schema when a qualified ref misses — that generated a migration for an unrelated table; the miss now produces the existing commented warning instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6 * docs(openapi): document the 15 hub publish proxy routes All /w/{workspace}/hub endpoints (draft/items/recordings/resource types/resources/triggers/migrations/export/submit/by-source) enter the public API contract with their body schemas derived from the serde structs, a shared HubProjectSlug schema encoding the slug validation, and passthrough text responses matching the proxy behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6 * fix(hub-projects): bundle $res refs nested in trigger configs, nullable trigger payload fields Trigger dependency collection now scans the full stripped config for $res:/res:// tokens (schedule args, on_*_extra_args, error_handler_args — e.g. the built-in Slack handler's channel resource) in addition to the kind-specific resource field, so those resources enter the bundle path map, get relocated by rewriteTriggerConfig, export a typed stub, and show up in the dependency pane. PublishTriggerBody's summary/description/ script_ask_id/flow_id become nullable in the OpenAPI contract, matching what the publisher actually sends and the Rust Options accept. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6 * fix(hub-projects): flow preprocessor/env refs, no cloud provisioning on import, config containment Flow extraction and rewriting now cover preprocessor_module (walked like any other module) and flow_env $res: values, so those dependencies are bundled and relocated instead of keeping source-workspace paths. GCP/Azure triggers are refused at import with an actionable message — their create endpoints manage cloud subscriptions before storing the trigger, even disabled, so auto-creating them from an import could mutate external infrastructure. The import containment guard now also validates everything a trigger config binds to (kind resource field, handler runnables incl. hub/ refs, nested $res: tokens), closing the path where a crafted export binds a trigger to assets outside the chosen folder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6 * refactor(hub-projects): per-kind config allowlists from a full trigger-field audit Every trigger kind's boundary-crossing config is now an explicit per-kind allowlist (configFields in TRIGGER_KINDS), derived from a field-by-field audit of every create type: portableTriggerConfig replaces the blocklist and is applied on export AND import, so an upstream field addition is dropped until consciously admitted (no more email-style leaks) and a crafted export can't inject fields like permissioned_as into create calls. The audit also surfaced unbundled websocket runnables — $script:/$flow: URLs and initial-message runnable_result paths are now collected and relocated — and drops GCP/Azure provisioned identities (subscription ids, delivery_config with the source instance's endpoint) from exports. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6 * fix(hub-projects): bundle $res refs nested in JSON flow_env values The worker resolves $res: references inside nested JSON flow_env values (transform_json walks the full value), so extraction and rewriting now scan the env's full serialization instead of only top-level strings. Also: the install-page Enterprise note includes GCP/Azure, the trigger-discovery Retry button binds to the loading state so clicks can't stack requests, and extractTriggerConfigResourceRefs no longer splits rewriteTriggerConfig from its doc comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6 * fix(hub-projects): scope $script:/$flow: relocation to the websocket url field The runnable-url form is only meaningful in that one field; remapping it on every nested config string could corrupt a literal payload that happens to look like one (e.g. a websocket initial raw_message). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6 * fix(hub-projects): nested static-transform refs, shared flow walk for migrations, top-level-only url remap Static input transforms accept arbitrary JSON and the worker resolves $res: refs nested inside them — extraction and rewriting now scan the full serialization, preserving the value's type. projectMigrations reuses projectBundle's allFlowModules instead of carrying its own module walk, so the preprocessor module (and any future module class) can't diverge between bundling and migration detection. The websocket $script:/$flow: url remap applies only at the config's top level, leaving nested url keys in args or handler payloads untouched. Schedule tag stays excluded by design (a source instance's worker-group name; a foreign tag queues jobs forever) — now documented in the allowlist contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6 * fix(hub-projects): remap prefixed runnable refs only in their known config fields script/<path> and flow/<path> forms are now rewritten only in the top-level schedule handler fields (on_failure/on_recovery/on_success), joining the url field treatment — shape-based remapping on arbitrary strings could rewrite a literal payload that merely looked like a handler ref. Bare-path exact matches and $res: tokens remain position-independent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6 * fix(hub-projects): abort stale-session imports after review, walk failure-module descendants Confirming a migration review whose project/workspace was switched away from now aborts with a toast before any write — previously the writes went to the old workspace with all feedback suppressed by the session guard. And allFlowModules puts the failure module in the root list so its nested children (loops/branches inside a failure handler) are expanded like every other module, for both bundling and migration detection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6 * fix(hub-projects): preserve app share state on Hub draft rehydration `rehydrateFromHub()` rebuilt `draftItems` from the Hub project payload, which carries only draft membership, so it dropped each app's `published`/`publicUrl` and app-table origin. Outside `predeploy` the UI reads `draftItems` exclusively, so reopening a draft showed a still-public app as unshared and removed its Unpublish control. Merge the live workspace-item state onto matching drafts after both `#loadWorkspace` and `rehydrateFromHub` (they race). Also gate the Share-as-iframe action on `canShareAsIframe`: legacy raw apps live only in the `raw_app` table, but that flow drives `AppService` (the `app` table) and fails with "App not found" for them, so the action is now hidden for legacy entries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hub-projects): stale-identity import guard, live publish state on drafts, iframe action gating The install session check now also compares the live slug and workspace to the captured ones — loadSeq only advances when a new load starts, so navigating away (workspace or ?hub becoming empty) previously left the stale migration review able to import into the captured workspace. Draft items are decorated with the live workspace item's shared-iframe fields (published/publicUrl/appTable) so a public app still shows as public after reopening a draft, settling reactively regardless of load order. The share-as-iframe action is offered only for apps and app-table raw apps — legacy raw_app entries have no AppService representation and the action could only fail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6 * refactor(deploy-to-hub): drive share-state merge from one reactive derived The rebase left two parallel fixes for the same rehydration gap: an imperative mergeShareState call after each racing load, and a read-time derived. Keep the pure, tested mergeShareState as the single implementation and invoke it from the derived — no load-completion call sites to maintain, and the merge settles whichever load finishes last. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6 * fix(hub-projects): block publish on unresolved refs; contain imported item refs Address two Codex findings: - Publish continued after `buildProjectBundle` reported unresolved references (a selected root or transitive runnable that failed to fetch, or a resource with no resolvable type), shipping a project whose items silently vanished or still pointed at the publisher's private source-workspace path. `#deployAll` now aborts before any Hub write when the bundle doesn't close, and the bundle drawer surfaces the unresolved list and disables "Create bundle". - `installProject` validated only each item's own path, so a crafted or incomplete export could place a script/flow/app inside the target folder while its `$res:`/script/flow reference stayed bound to an existing `u/...` or other `f/...` asset. Extract each item's live references and reject any that escape `f/<folder>/` (hub/ script refs allowed), mirroring the existing trigger-config containment check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hub-projects): contain imported $var refs; dedupe unresolved list Follow-up to the publish-blocker and import-containment fixes: - `$var:` references (flow static inputs, flow_env, app values, and trigger config fields such as SQS queue_url) were not caught by the containment check, which only recognized `$res:`/runnable refs. Retargeting leaves them unchanged, so an export with `$var:u/admin/token` imported an item that resolves a variable outside the target folder under the runnable's permissions. Scan each imported flow/app/trigger for `$var:` tokens and reject out-of-folder ones. Scripts are skipped: `$var:` is resolved in job args, not script source. - `buildProjectBundle` stored bare paths in `unresolved` while keying missing items by kind:path, so a script and flow sharing a missing path produced a duplicate string. The new keyed unresolved list in the bundle drawer then hit Svelte's duplicate-key runtime error instead of rendering the publish blocker. Dedupe `unresolved` at the source. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hub-projects): contain $var/$jsonvar imports; retryable partial publish; iframe rollback Address four Codex findings: - `$jsonvar:` (secret JSON args) was not contained on import, and scanning the serialized flow/app for `$var:` tokens falsely rejected inline-code literals. The worker only substitutes a variable when an argument value *is* the reference (whole value, walking nested JSON), never a token embedded in code. Replace the token scan with a structural whole-value walk (`$var:`/`$jsonvar:`) and reject out-of-folder refs in flows, apps, and trigger config. Scripts carry no variable args, so they are skipped. - A partial publish (failed item/trigger/migration write) still transitioned to the submit-ready `draft` phase. Stay in the retryable `predeploy` state on any failure, keeping the failed items visible, so nothing incomplete can be submitted and re-publishing retries every idempotent write. - `#setAppShared` flipped a raw app public before checking its Hub item id or syncing the embed, so a missing id or a failed embed sync left the app publicly accessible while reporting failure. Validate the Hub target up front and roll the policy back if the embed sync fails. - `buildProjectBundle` could emit duplicate unresolved paths (a script and flow sharing a missing path), breaking the keyed publish-blocker render. Deduped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hub-projects): full-set trigger sync; count iframe re-sync + URL failures Address three Codex findings, two of them refinements of the incomplete-publish gate and iframe-rollback fixes: - `#pushTriggers` returned early on an empty set, so re-deploying a project after removing all its triggers left the previous Hub triggers intact. Always post the trigger list (an empty one clears them), mirroring the migrations full-set sync. - A raw app's post-deploy iframe re-sync failure only toasted; it now increments `failures`, so a public app left with a stale embed keeps the draft out of the submit-ready phase. - `#setAppShared` skipped the embed and still returned success when the public URL couldn't be resolved, leaving the app anonymous with no usable link. It now rolls the policy back and throws when a share has no resolvable URL, alongside the existing embed-failure rollback (factored into one helper). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hub-projects): count URL-less iframe re-sync; evict failed preview caches Two Codex findings, both refinements of earlier fixes: - The post-deploy iframe re-sync skipped a published raw app whose public URL was missing (URL resolution had failed) without counting it, so the re-bundle left the app public with a cleared Hub embed yet the draft still became submit-ready. Treat a published raw app with no resolvable URL as an incomplete publish and count it like a push failure. - The bundle-preview dependency caches memoized promises that resolve to undefined after transient item/resource fetch failures, so fixing or retrying a dependency could never clear `bundlePreview.unresolved` and the Create bundle button stayed disabled until the session was recreated. Evict a cache entry once it resolves to undefined so a later rebuild re-fetches. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hub-projects): keep Unpublish for a public app whose URL didn't resolve The iframe controls required both `published` and `publicUrl`, so an anonymous app whose public-URL lookup failed rendered as unshared with only a Share action and no way to unpublish. Branch the Public badge and Unpublish on `published` alone, gate the URL-dependent Open/Copy-iframe actions on `publicUrl`, and offer a Retry link that re-resolves the URL when it is missing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(hub-projects): make $var/$jsonvar dependencies portable on import Variable references were neither retargeted nor materialized, so a published project that used a variable broke on import: a renamed-folder import rejected the containing item (the `$var:` kept the old folder prefix), and a same-folder import left the reference dangling (the target variable never existed). Treat variables like resource stubs, fully on the import side (their `$var:`/ `$jsonvar:` refs already travel inside the exported item values): - `buildRetargetMap` now also relocates the internal variable paths embedded in the export's flows/apps/triggers, and `rewriteContent` rewrites `$var:`/ `$jsonvar:` tokens (kind preserved) for any path in the map — so the publish map, which omits variables, is unaffected. - `installProject` creates an empty secret placeholder for each in-folder variable ref, conflict-safe via `existsVariable`, for the importer to fill. Values are never shipped. External refs stay rejected by containment. Custom resource-type definitions (the sibling finding) are intentionally left to the standardized official Hub resource types, so no schema import is needed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hub-projects): relocate $var refs structurally, never in inline code Routing variable retargeting through `rewriteContent` also rewrote `$var:`/ `$jsonvar:` tokens embedded in script source, inline rawscript, and serialized app strings, so an inert literal sharing a real variable's path was silently altered on a renamed-folder import — contradicting the whole-string runtime-reference rule. Relocate variables with a structural walk (`rewriteVarRefsInValue`) that rewrites only whole-string `$var:`/`$jsonvar:` values (the sole form the worker resolves), applied to flow/app/trigger values in `retargetProjectExport`; `rewriteContent` is back to `$res:`-only. Inline code literals are left untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hub-projects): relocate $var refs into the slug at publish Import-side retargeting assumed exported `$var:`/`$jsonvar:` refs already began with the project slug, but `buildProjectBundle` never relocated them from the source folder. Publishing `f/source_folder/...` as slug `my-toolkit` therefore exported `$var:f/source_folder/key`; import (fromSlug=my-toolkit) left it unchanged and containment rejected the item. Collect each item's runtime variable refs, feed them through the same path map that relocates items/resources into `f/<slug>/`, and structurally rewrite the whole-value refs — symmetric with the import retarget. The export is now slug-relative whatever the source folder, and inline-code literals stay untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hub-projects): relocate trigger config $var refs at publish Item variable refs were relocated into the slug, but triggers publish through a separate path (`#pushTriggers` → `rewriteTriggerConfig`), which doesn't touch `$var:`/`$jsonvar:`. Publishing `f/source/...` under a different Hub slug left schedule args and other config refs pointing at `f/source/...`, and import containment then rejected the trigger. Collect each trigger config's whole-string variable refs (`#triggerVarPaths`), feed them through the bundle path map via a new `extraVarPaths` arg to `buildProjectBundle`, and structurally rewrite the config on publish. Symmetric with the item and import-side handling; the import retarget already relocated trigger vars. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(hub-projects): correct varContainmentViolation retargeting contract The comment claimed retargeting doesn't rewrite variable refs; it now relocates a project's own refs into the target folder, and containment rejects only those left outside it. Describe the current behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com> Co-authored-by: hugocasa <hugo@casademont.ch> Co-authored-by: centdix <40307056+centdix@users.noreply.github.com> Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> Co-authored-by: Aldrin Jenson <aldrinjenson@gmail.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: centdix <centdix@users.noreply.github.com> Co-authored-by: Alexander Petric <alpetric@users.noreply.github.com> Co-authored-by: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Co-authored-by: Guilhem <guilhemlemouel@gmail.com> Co-authored-by: Diego Imbert <diego@windmill.dev>
This commit is contained in:
@@ -22895,6 +22895,438 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/hub/publish_draft:
|
||||
post:
|
||||
summary: create or update a hub project draft
|
||||
description: |
|
||||
Requires the caller to be a workspace admin. Forwards the request to the
|
||||
configured Hub scoped to the `{workspace}:{folder}` source and returns
|
||||
the Hub's status code and raw response body.
|
||||
operationId: publishHubDraft
|
||||
tags:
|
||||
- hubPublish
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/HubPublishFolder"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PublishDraftBody"
|
||||
responses:
|
||||
"200":
|
||||
description: raw Hub response body (status code is passed through from the Hub)
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/hub/scripts:
|
||||
post:
|
||||
summary: publish a script to a hub project
|
||||
description: |
|
||||
Requires the caller to be a workspace admin. Forwards the request to the
|
||||
configured Hub scoped to the `{workspace}:{folder}` source and returns
|
||||
the Hub's status code and raw response body.
|
||||
operationId: publishHubScript
|
||||
tags:
|
||||
- hubPublish
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/HubPublishFolder"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PublishScriptBody"
|
||||
responses:
|
||||
"200":
|
||||
description: raw Hub response body (status code is passed through from the Hub)
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/hub/flows:
|
||||
post:
|
||||
summary: publish a flow to a hub project
|
||||
description: |
|
||||
Requires the caller to be a workspace admin. Forwards the request to the
|
||||
configured Hub scoped to the `{workspace}:{folder}` source and returns
|
||||
the Hub's status code and raw response body.
|
||||
operationId: publishHubFlow
|
||||
tags:
|
||||
- hubPublish
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/HubPublishFolder"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PublishFlowBody"
|
||||
responses:
|
||||
"200":
|
||||
description: raw Hub response body (status code is passed through from the Hub)
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/hub/apps:
|
||||
post:
|
||||
summary: publish an app to a hub project
|
||||
description: |
|
||||
Requires the caller to be a workspace admin. Forwards the request to the
|
||||
configured Hub scoped to the `{workspace}:{folder}` source and returns
|
||||
the Hub's status code and raw response body.
|
||||
operationId: publishHubApp
|
||||
tags:
|
||||
- hubPublish
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/HubPublishFolder"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PublishAppBody"
|
||||
responses:
|
||||
"200":
|
||||
description: raw Hub response body (status code is passed through from the Hub)
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/hub/raw_apps:
|
||||
post:
|
||||
summary: publish a raw app to a hub project
|
||||
description: |
|
||||
Requires the caller to be a workspace admin. Forwards the request to the
|
||||
configured Hub scoped to the `{workspace}:{folder}` source and returns
|
||||
the Hub's status code and raw response body.
|
||||
operationId: publishHubRawApp
|
||||
tags:
|
||||
- hubPublish
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/HubPublishFolder"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PublishRawAppBody"
|
||||
responses:
|
||||
"200":
|
||||
description: raw Hub response body (status code is passed through from the Hub)
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/hub/raw_apps/{id}/embed:
|
||||
post:
|
||||
summary: set or clear the embed url of a hub raw app
|
||||
description: |
|
||||
Requires the caller to be a workspace admin. Forwards the request to the
|
||||
configured Hub scoped to the `{workspace}:{folder}` source and returns
|
||||
the Hub's status code and raw response body.
|
||||
operationId: publishHubRawAppEmbed
|
||||
tags:
|
||||
- hubPublish
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: hub id of the raw app
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
- $ref: "#/components/parameters/HubPublishFolder"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RawAppEmbedBody"
|
||||
responses:
|
||||
"200":
|
||||
description: raw Hub response body (status code is passed through from the Hub)
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/hub/scripts/{ask_id}/recording:
|
||||
post:
|
||||
summary: attach a recording to a hub script
|
||||
description: |
|
||||
Requires the caller to be a workspace admin. Forwards the request to the
|
||||
configured Hub scoped to the `{workspace}:{folder}` source and returns
|
||||
the Hub's status code and raw response body.
|
||||
operationId: publishHubScriptRecording
|
||||
tags:
|
||||
- hubPublish
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: ask_id
|
||||
in: path
|
||||
required: true
|
||||
description: hub ask id of the script
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
- $ref: "#/components/parameters/HubPublishFolder"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RecordingBody"
|
||||
responses:
|
||||
"200":
|
||||
description: raw Hub response body (status code is passed through from the Hub)
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/hub/flows/{flow_id}/recording:
|
||||
post:
|
||||
summary: attach a recording to a hub flow
|
||||
description: |
|
||||
Requires the caller to be a workspace admin. Forwards the request to the
|
||||
configured Hub scoped to the `{workspace}:{folder}` source and returns
|
||||
the Hub's status code and raw response body.
|
||||
operationId: publishHubFlowRecording
|
||||
tags:
|
||||
- hubPublish
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: flow_id
|
||||
in: path
|
||||
required: true
|
||||
description: hub id of the flow
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
- $ref: "#/components/parameters/HubPublishFolder"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RecordingBody"
|
||||
responses:
|
||||
"200":
|
||||
description: raw Hub response body (status code is passed through from the Hub)
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/hub/resource_types:
|
||||
post:
|
||||
summary: publish a resource type to a hub project
|
||||
description: |
|
||||
Requires the caller to be a workspace admin. Forwards the request to the
|
||||
configured Hub scoped to the `{workspace}:{folder}` source and returns
|
||||
the Hub's status code and raw response body.
|
||||
operationId: publishHubResourceType
|
||||
tags:
|
||||
- hubPublish
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/HubPublishFolder"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PublishResourceTypeBody"
|
||||
responses:
|
||||
"200":
|
||||
description: raw Hub response body (status code is passed through from the Hub)
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/hub/resources:
|
||||
post:
|
||||
summary: publish resource placeholders to a hub project
|
||||
description: |
|
||||
Requires the caller to be a workspace admin. Forwards the request to the
|
||||
configured Hub scoped to the `{workspace}:{folder}` source and returns
|
||||
the Hub's status code and raw response body.
|
||||
operationId: publishHubResources
|
||||
tags:
|
||||
- hubPublish
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/HubPublishFolder"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PublishResourcesBody"
|
||||
responses:
|
||||
"200":
|
||||
description: raw Hub response body (status code is passed through from the Hub)
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/hub/triggers:
|
||||
post:
|
||||
summary: publish triggers to a hub project
|
||||
description: |
|
||||
Requires the caller to be a workspace admin. Forwards the request to the
|
||||
configured Hub scoped to the `{workspace}:{folder}` source and returns
|
||||
the Hub's status code and raw response body.
|
||||
operationId: publishHubTriggers
|
||||
tags:
|
||||
- hubPublish
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/HubPublishFolder"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PublishTriggersBody"
|
||||
responses:
|
||||
"200":
|
||||
description: raw Hub response body (status code is passed through from the Hub)
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/hub/migrations:
|
||||
post:
|
||||
summary: publish data table migrations to a hub project
|
||||
description: |
|
||||
Requires the caller to be a workspace admin. Forwards the request to the
|
||||
configured Hub scoped to the `{workspace}:{folder}` source and returns
|
||||
the Hub's status code and raw response body.
|
||||
operationId: publishHubMigrations
|
||||
tags:
|
||||
- hubPublish
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/HubPublishFolder"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PublishMigrationsBody"
|
||||
responses:
|
||||
"200":
|
||||
description: raw Hub response body (status code is passed through from the Hub)
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/hub/projects/{slug}/export:
|
||||
get:
|
||||
summary: export a hub project
|
||||
description: |
|
||||
Requires the caller to be a workspace admin. Forwards the request to the
|
||||
configured Hub and returns the Hub's status code and raw response body.
|
||||
The folder scope is only needed to re-export the caller's own draft;
|
||||
approved projects are public, so it is optional here.
|
||||
operationId: getHubProjectExport
|
||||
tags:
|
||||
- hubPublish
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: slug
|
||||
in: path
|
||||
required: true
|
||||
description: hub project slug (3-50 chars, lowercase alphanumeric and hyphens, no leading/trailing hyphen)
|
||||
schema:
|
||||
type: string
|
||||
minLength: 3
|
||||
maxLength: 50
|
||||
pattern: "^[a-z0-9][a-z0-9-]*[a-z0-9]$"
|
||||
- name: folder
|
||||
in: query
|
||||
required: false
|
||||
description: folder scoping the Hub project source (`{workspace}:{folder}`)
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: raw Hub response body (status code is passed through from the Hub)
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/hub/projects/{slug}/submit:
|
||||
post:
|
||||
summary: submit a hub project draft for review
|
||||
description: |
|
||||
Requires the caller to be a workspace admin. Forwards the request to the
|
||||
configured Hub scoped to the `{workspace}:{folder}` source and returns
|
||||
the Hub's status code and raw response body.
|
||||
operationId: submitHubProject
|
||||
tags:
|
||||
- hubPublish
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: slug
|
||||
in: path
|
||||
required: true
|
||||
description: hub project slug (3-50 chars, lowercase alphanumeric and hyphens, no leading/trailing hyphen)
|
||||
schema:
|
||||
type: string
|
||||
minLength: 3
|
||||
maxLength: 50
|
||||
pattern: "^[a-z0-9][a-z0-9-]*[a-z0-9]$"
|
||||
- $ref: "#/components/parameters/HubPublishFolder"
|
||||
responses:
|
||||
"200":
|
||||
description: raw Hub response body (status code is passed through from the Hub)
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/hub/project:
|
||||
get:
|
||||
summary: get the hub project linked to a workspace folder
|
||||
description: |
|
||||
Requires the caller to be a workspace admin. Forwards the request to the
|
||||
configured Hub scoped to the `{workspace}:{folder}` source and returns
|
||||
the Hub's status code and raw response body.
|
||||
operationId: getHubProjectBySource
|
||||
tags:
|
||||
- hubPublish
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/HubPublishFolder"
|
||||
responses:
|
||||
"200":
|
||||
description: raw Hub response body (status code is passed through from the Hub)
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
@@ -22943,6 +23375,16 @@ components:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
HubPublishFolder:
|
||||
name: folder
|
||||
in: query
|
||||
required: true
|
||||
description: |
|
||||
workspace folder scoping the Hub publication: a workspace can publish
|
||||
one Hub project per folder and the Hub-side source key is
|
||||
`{workspace}:{folder}`
|
||||
schema:
|
||||
type: string
|
||||
PublicationName:
|
||||
name: publication
|
||||
in: path
|
||||
@@ -31839,3 +32281,276 @@ components:
|
||||
- name
|
||||
- owner
|
||||
- private
|
||||
|
||||
HubProjectSlug:
|
||||
type: string
|
||||
description: hub project slug (3-50 chars, lowercase alphanumeric and hyphens, no leading/trailing hyphen)
|
||||
minLength: 3
|
||||
maxLength: 50
|
||||
pattern: "^[a-z0-9][a-z0-9-]*[a-z0-9]$"
|
||||
|
||||
PublishDraftBody:
|
||||
type: object
|
||||
properties:
|
||||
slug:
|
||||
$ref: "#/components/schemas/HubProjectSlug"
|
||||
name:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
readme:
|
||||
type: string
|
||||
required:
|
||||
- slug
|
||||
- name
|
||||
- summary
|
||||
|
||||
PublishScriptBody:
|
||||
type: object
|
||||
properties:
|
||||
summary:
|
||||
type: string
|
||||
app:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
content:
|
||||
type: string
|
||||
language:
|
||||
type: string
|
||||
schema:
|
||||
type: object
|
||||
lockfile:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
source_path:
|
||||
type: string
|
||||
project_slug:
|
||||
$ref: "#/components/schemas/HubProjectSlug"
|
||||
required:
|
||||
- summary
|
||||
- app
|
||||
- content
|
||||
- language
|
||||
- project_slug
|
||||
|
||||
PublishFlowInner:
|
||||
type: object
|
||||
properties:
|
||||
summary:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
value:
|
||||
type: object
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- summary
|
||||
- value
|
||||
|
||||
PublishFlowBody:
|
||||
type: object
|
||||
properties:
|
||||
flow:
|
||||
$ref: "#/components/schemas/PublishFlowInner"
|
||||
apps:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
source_path:
|
||||
type: string
|
||||
project_slug:
|
||||
$ref: "#/components/schemas/HubProjectSlug"
|
||||
required:
|
||||
- flow
|
||||
- apps
|
||||
- project_slug
|
||||
|
||||
PublishAppBody:
|
||||
type: object
|
||||
properties:
|
||||
app:
|
||||
type: object
|
||||
apps:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
source_path:
|
||||
type: string
|
||||
project_slug:
|
||||
$ref: "#/components/schemas/HubProjectSlug"
|
||||
required:
|
||||
- app
|
||||
- apps
|
||||
- summary
|
||||
- project_slug
|
||||
|
||||
PublishRawAppBody:
|
||||
type: object
|
||||
properties:
|
||||
raw:
|
||||
type: string
|
||||
apps:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
source_path:
|
||||
type: string
|
||||
project_slug:
|
||||
$ref: "#/components/schemas/HubProjectSlug"
|
||||
required:
|
||||
- raw
|
||||
- apps
|
||||
- summary
|
||||
- project_slug
|
||||
|
||||
RawAppEmbedBody:
|
||||
type: object
|
||||
properties:
|
||||
external_embed_url:
|
||||
type: string
|
||||
nullable: true
|
||||
description: explicit `null` clears the embed (unpublish)
|
||||
project_slug:
|
||||
$ref: "#/components/schemas/HubProjectSlug"
|
||||
required:
|
||||
- project_slug
|
||||
|
||||
RecordingBody:
|
||||
type: object
|
||||
properties:
|
||||
recording:
|
||||
type: object
|
||||
project_slug:
|
||||
$ref: "#/components/schemas/HubProjectSlug"
|
||||
required:
|
||||
- project_slug
|
||||
|
||||
PublishResourceTypeBody:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
schema:
|
||||
type: object
|
||||
description:
|
||||
type: string
|
||||
project_slug:
|
||||
$ref: "#/components/schemas/HubProjectSlug"
|
||||
required:
|
||||
- name
|
||||
- project_slug
|
||||
|
||||
PublishResourceBody:
|
||||
type: object
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
resource_type:
|
||||
type: string
|
||||
required:
|
||||
- path
|
||||
- resource_type
|
||||
|
||||
PublishResourcesBody:
|
||||
type: object
|
||||
properties:
|
||||
resources:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/PublishResourceBody"
|
||||
project_slug:
|
||||
$ref: "#/components/schemas/HubProjectSlug"
|
||||
required:
|
||||
- resources
|
||||
- project_slug
|
||||
|
||||
PublishTriggerBody:
|
||||
type: object
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
nullable: true
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
config:
|
||||
type: object
|
||||
script_ask_id:
|
||||
type: integer
|
||||
format: int64
|
||||
nullable: true
|
||||
flow_id:
|
||||
type: integer
|
||||
format: int64
|
||||
nullable: true
|
||||
required:
|
||||
- path
|
||||
- kind
|
||||
- config
|
||||
|
||||
PublishTriggersBody:
|
||||
type: object
|
||||
properties:
|
||||
triggers:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/PublishTriggerBody"
|
||||
project_slug:
|
||||
$ref: "#/components/schemas/HubProjectSlug"
|
||||
required:
|
||||
- triggers
|
||||
- project_slug
|
||||
|
||||
PublishMigrationBody:
|
||||
type: object
|
||||
description: one best-effort data table migration attached to a project (per data table)
|
||||
properties:
|
||||
datatable_name:
|
||||
type: string
|
||||
sql:
|
||||
type: string
|
||||
sql_down:
|
||||
type: string
|
||||
description: defaults to an empty string when omitted
|
||||
enabled:
|
||||
type: boolean
|
||||
required:
|
||||
- datatable_name
|
||||
- sql
|
||||
- enabled
|
||||
|
||||
PublishMigrationsBody:
|
||||
type: object
|
||||
properties:
|
||||
migrations:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/PublishMigrationBody"
|
||||
project_slug:
|
||||
$ref: "#/components/schemas/HubProjectSlug"
|
||||
required:
|
||||
- migrations
|
||||
- project_slug
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
use crate::auth::Tokened;
|
||||
use crate::db::ApiAuthed;
|
||||
use crate::HTTP_CLIENT;
|
||||
use axum::{
|
||||
extract::{FromRequestParts, Json, Path, Query, RawPathParams},
|
||||
http::{request::Parts, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use windmill_common::{
|
||||
error::{to_anyhow, Error},
|
||||
utils::require_admin,
|
||||
HUB_BASE_URL,
|
||||
};
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/publish_draft", post(publish_draft))
|
||||
.route("/scripts", post(publish_script))
|
||||
.route("/flows", post(publish_flow))
|
||||
.route("/apps", post(publish_app))
|
||||
.route("/raw_apps", post(publish_raw_app))
|
||||
.route("/raw_apps/{id}/embed", post(publish_raw_app_embed))
|
||||
.route(
|
||||
"/scripts/{ask_id}/recording",
|
||||
post(publish_script_recording),
|
||||
)
|
||||
.route("/flows/{flow_id}/recording", post(publish_flow_recording))
|
||||
.route("/resource_types", post(publish_resource_type))
|
||||
.route("/resources", post(publish_resources))
|
||||
.route("/triggers", post(publish_triggers))
|
||||
.route("/migrations", post(publish_migrations))
|
||||
.route("/projects/{slug}/export", get(get_project_export))
|
||||
.route("/projects/{slug}/submit", post(submit_project))
|
||||
.route("/project", get(get_project_by_source))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct HubScope {
|
||||
folder: Option<String>,
|
||||
}
|
||||
|
||||
fn validate_folder(folder: &str) -> Result<(), Error> {
|
||||
let ok = !folder.is_empty()
|
||||
&& folder.len() <= 255
|
||||
&& folder
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-');
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::BadRequest(format!("invalid folder: {folder}")))
|
||||
}
|
||||
}
|
||||
|
||||
fn source_key(workspace: &str, folder: &str) -> Result<String, Error> {
|
||||
validate_folder(folder)?;
|
||||
Ok(format!("{workspace}:{folder}"))
|
||||
}
|
||||
|
||||
fn validate_project_slug(slug: &str) -> Result<(), Error> {
|
||||
let ok = slug.len() >= 3
|
||||
&& slug.len() <= 50
|
||||
&& !slug.starts_with('-')
|
||||
&& !slug.ends_with('-')
|
||||
&& slug
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::BadRequest(format!("invalid project slug: {slug}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// A Hub project slug that is valid by construction: deserialization (from a
|
||||
/// request body or a path segment) is the only way to obtain one and it runs
|
||||
/// `validate_project_slug`, so no handler can forward or interpolate an
|
||||
/// unvalidated slug into a Hub URL.
|
||||
#[derive(Serialize)]
|
||||
#[serde(transparent)]
|
||||
struct ProjectSlug(String);
|
||||
|
||||
impl<'de> Deserialize<'de> for ProjectSlug {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let s = String::deserialize(d)?;
|
||||
validate_project_slug(&s).map_err(serde::de::Error::custom)?;
|
||||
Ok(ProjectSlug(s))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ProjectSlug {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The single gate every Hub endpoint goes through: an admin caller, their
|
||||
/// token (the Hub authenticates it back against this instance's whoami), and
|
||||
/// the validated `workspace_id:folder` source key scoping ownership Hub-side.
|
||||
/// Handlers can only reach the Hub via this extractor's methods, so a new
|
||||
/// endpoint cannot forget the admin check or folder validation.
|
||||
///
|
||||
/// A workspace can publish one Hub project per folder. The stable, never-mutated
|
||||
/// link key is `workspace_id:folder_name` (folder name is the path segment and is
|
||||
/// never renamed — only display_name changes). `:` is safe: neither workspace ids
|
||||
/// nor folder names (alphanumeric, underscore, hyphen) contain it.
|
||||
struct HubPublishCtx {
|
||||
source_id: Option<String>,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl<S> FromRequestParts<S> for HubPublishCtx
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &S,
|
||||
) -> std::result::Result<Self, Self::Rejection> {
|
||||
let authed = ApiAuthed::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(IntoResponse::into_response)?;
|
||||
let tokened = Tokened::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(IntoResponse::into_response)?;
|
||||
let params = RawPathParams::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(IntoResponse::into_response)?;
|
||||
let workspace = params
|
||||
.iter()
|
||||
.find(|(k, _)| *k == "workspace_id")
|
||||
.map(|(_, v)| v.to_owned());
|
||||
let Query(scope) = Query::<HubScope>::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(IntoResponse::into_response)?;
|
||||
let build = || -> Result<HubPublishCtx, Error> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let workspace = workspace.ok_or_else(|| {
|
||||
Error::internal_err(
|
||||
"hub publish route must be nested under /w/{workspace_id}".to_string(),
|
||||
)
|
||||
})?;
|
||||
let source_id = scope
|
||||
.folder
|
||||
.as_deref()
|
||||
.map(|f| source_key(&workspace, f))
|
||||
.transpose()?;
|
||||
Ok(HubPublishCtx { source_id, token: tokened.token })
|
||||
};
|
||||
build().map_err(IntoResponse::into_response)
|
||||
}
|
||||
}
|
||||
|
||||
impl HubPublishCtx {
|
||||
fn require_source(&self) -> Result<&str, Error> {
|
||||
self.source_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| Error::BadRequest("missing folder query param".to_string()))
|
||||
}
|
||||
|
||||
async fn post<T: Serialize>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &T,
|
||||
) -> Result<(StatusCode, String), Error> {
|
||||
forward_to_hub(path, self.require_source()?, &self.token, body).await
|
||||
}
|
||||
|
||||
async fn get(&self, path: &str) -> Result<(StatusCode, String), Error> {
|
||||
get_from_hub(path, self.require_source()?, &self.token).await
|
||||
}
|
||||
|
||||
/// GET without requiring a folder scope. Only for reads the Hub allows
|
||||
/// publicly (e.g. exporting an approved project); the empty source id makes
|
||||
/// the Hub skip the ownership match.
|
||||
async fn get_maybe_unscoped(&self, path: &str) -> Result<(StatusCode, String), Error> {
|
||||
get_from_hub(path, self.source_id.as_deref().unwrap_or(""), &self.token).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct PublishDraftBody {
|
||||
slug: ProjectSlug,
|
||||
name: String,
|
||||
summary: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
readme: Option<String>,
|
||||
}
|
||||
|
||||
async fn publish_draft(
|
||||
ctx: HubPublishCtx,
|
||||
Json(body): Json<PublishDraftBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
ctx.post("/projects", &body).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct PublishScriptBody {
|
||||
summary: String,
|
||||
app: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
kind: Option<String>,
|
||||
content: String,
|
||||
language: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
schema: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
lockfile: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
source_path: Option<String>,
|
||||
project_slug: ProjectSlug,
|
||||
}
|
||||
|
||||
async fn publish_script(
|
||||
ctx: HubPublishCtx,
|
||||
Json(body): Json<PublishScriptBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
ctx.post("/scripts/add", &body).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct PublishFlowInner {
|
||||
summary: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
value: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
schema: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct PublishFlowBody {
|
||||
flow: PublishFlowInner,
|
||||
apps: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
source_path: Option<String>,
|
||||
project_slug: ProjectSlug,
|
||||
}
|
||||
|
||||
async fn publish_flow(
|
||||
ctx: HubPublishCtx,
|
||||
Json(body): Json<PublishFlowBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
ctx.post("/flows", &body).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct PublishAppBody {
|
||||
app: serde_json::Value,
|
||||
apps: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
summary: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
source_path: Option<String>,
|
||||
project_slug: ProjectSlug,
|
||||
}
|
||||
|
||||
async fn publish_app(
|
||||
ctx: HubPublishCtx,
|
||||
Json(body): Json<PublishAppBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
ctx.post("/apps", &body).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct PublishRawAppBody {
|
||||
raw: String,
|
||||
apps: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
summary: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
source_path: Option<String>,
|
||||
project_slug: ProjectSlug,
|
||||
}
|
||||
|
||||
async fn publish_raw_app(
|
||||
ctx: HubPublishCtx,
|
||||
Json(body): Json<PublishRawAppBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
ctx.post("/raw_apps", &body).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct RawAppEmbedBody {
|
||||
// No skip_serializing_if: `null` must reach the Hub to clear the embed (unpublish).
|
||||
external_embed_url: Option<String>,
|
||||
project_slug: ProjectSlug,
|
||||
}
|
||||
|
||||
async fn publish_raw_app_embed(
|
||||
ctx: HubPublishCtx,
|
||||
Path((_workspace, id)): Path<(String, i64)>,
|
||||
Json(body): Json<RawAppEmbedBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
ctx.post(&format!("/raw_apps/{}/embed", id), &body).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct RecordingBody {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
recording: Option<serde_json::Value>,
|
||||
project_slug: ProjectSlug,
|
||||
}
|
||||
|
||||
async fn publish_script_recording(
|
||||
ctx: HubPublishCtx,
|
||||
Path((_workspace, ask_id)): Path<(String, i64)>,
|
||||
Json(body): Json<RecordingBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
ctx.post(&format!("/scripts/{}/recording", ask_id), &body)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn publish_flow_recording(
|
||||
ctx: HubPublishCtx,
|
||||
Path((_workspace, flow_id)): Path<(String, i64)>,
|
||||
Json(body): Json<RecordingBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
ctx.post(&format!("/flows/{}/recording", flow_id), &body)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct PublishResourceTypeBody {
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
schema: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
project_slug: ProjectSlug,
|
||||
}
|
||||
|
||||
async fn publish_resource_type(
|
||||
ctx: HubPublishCtx,
|
||||
Json(body): Json<PublishResourceTypeBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
ctx.post(
|
||||
&format!("/projects/{}/resource_types", body.project_slug),
|
||||
&body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct PublishResourceBody {
|
||||
path: String,
|
||||
resource_type: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct PublishResourcesBody {
|
||||
resources: Vec<PublishResourceBody>,
|
||||
project_slug: ProjectSlug,
|
||||
}
|
||||
|
||||
async fn publish_resources(
|
||||
ctx: HubPublishCtx,
|
||||
Json(body): Json<PublishResourcesBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
ctx.post(&format!("/projects/{}/resources", body.project_slug), &body)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct PublishTriggerBody {
|
||||
path: String,
|
||||
kind: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
summary: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
config: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
script_ask_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
flow_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct PublishTriggersBody {
|
||||
triggers: Vec<PublishTriggerBody>,
|
||||
project_slug: ProjectSlug,
|
||||
}
|
||||
|
||||
async fn publish_triggers(
|
||||
ctx: HubPublishCtx,
|
||||
Json(body): Json<PublishTriggersBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
ctx.post(&format!("/projects/{}/triggers", body.project_slug), &body)
|
||||
.await
|
||||
}
|
||||
|
||||
// One best-effort data table migration attached to a project (per data table).
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct PublishMigrationBody {
|
||||
datatable_name: String,
|
||||
sql: String,
|
||||
#[serde(default)]
|
||||
sql_down: String,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct PublishMigrationsBody {
|
||||
migrations: Vec<PublishMigrationBody>,
|
||||
project_slug: ProjectSlug,
|
||||
}
|
||||
|
||||
async fn publish_migrations(
|
||||
ctx: HubPublishCtx,
|
||||
Json(body): Json<PublishMigrationsBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
ctx.post(
|
||||
&format!("/projects/{}/migrations", body.project_slug),
|
||||
&body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// Export is owner-scoped only when re-exporting your own draft; approved
|
||||
// projects are public, so the folder scope is optional here.
|
||||
async fn get_project_export(
|
||||
ctx: HubPublishCtx,
|
||||
Path((_workspace, slug)): Path<(String, ProjectSlug)>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
ctx.get_maybe_unscoped(&format!("/projects/{}/export", slug))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_project_by_source(ctx: HubPublishCtx) -> Result<impl IntoResponse, Error> {
|
||||
ctx.get("/projects/by_source").await
|
||||
}
|
||||
|
||||
async fn submit_project(
|
||||
ctx: HubPublishCtx,
|
||||
Path((_workspace, slug)): Path<(String, ProjectSlug)>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
ctx.post(
|
||||
&format!("/projects/{}/submit", slug),
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// The Hub has no auth of its own: it validates bearer tokens by calling this
|
||||
// instance's /api/users/whoami. Forwarding the caller's own token logs them in
|
||||
// on the Hub as themselves (account auto-created on first use).
|
||||
async fn get_from_hub(
|
||||
path: &str,
|
||||
source_id: &str,
|
||||
token: &str,
|
||||
) -> Result<(StatusCode, String), Error> {
|
||||
let url = format!("{}{}", **HUB_BASE_URL.load(), path);
|
||||
|
||||
let res = HTTP_CLIENT
|
||||
.get(&url)
|
||||
.query(&[("source_id", source_id)])
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("hub request failed: {e}")))?;
|
||||
|
||||
let status = StatusCode::from_u16(res.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||
let text = res
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("hub response read failed: {e}")))?;
|
||||
|
||||
Ok((status, text))
|
||||
}
|
||||
|
||||
async fn forward_to_hub<T: Serialize>(
|
||||
path: &str,
|
||||
source_id: &str,
|
||||
token: &str,
|
||||
body: &T,
|
||||
) -> Result<(StatusCode, String), Error> {
|
||||
let url = format!("{}{}", **HUB_BASE_URL.load(), path);
|
||||
|
||||
let mut payload = serde_json::to_value(body).map_err(to_anyhow)?;
|
||||
let obj = payload
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| Error::internal_err("hub publish body must be a JSON object".to_string()))?;
|
||||
obj.insert(
|
||||
"source_id".to_string(),
|
||||
serde_json::Value::String(source_id.to_string()),
|
||||
);
|
||||
|
||||
let res = HTTP_CLIENT
|
||||
.post(&url)
|
||||
.bearer_auth(token)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("hub request failed: {e}")))?;
|
||||
|
||||
let status = StatusCode::from_u16(res.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||
let text = res
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("hub response read failed: {e}")))?;
|
||||
|
||||
Ok((status, text))
|
||||
}
|
||||
@@ -94,6 +94,7 @@ mod granular_acls;
|
||||
mod group_history;
|
||||
mod groups;
|
||||
mod health;
|
||||
mod hub_publish;
|
||||
#[cfg(feature = "private")]
|
||||
pub mod indexer_ee;
|
||||
mod indexer_oss;
|
||||
@@ -656,6 +657,7 @@ pub async fn run_server(
|
||||
.nest("/volumes", volumes_oss::workspaced_service())
|
||||
.nest("/workers", windmill_api_workers::workspaced_service())
|
||||
.nest("/workspaces", workspaces::workspaced_service())
|
||||
.nest("/hub", hub_publish::workspaced_service())
|
||||
.nest(
|
||||
"/data_metrics",
|
||||
windmill_api_workspaces::data_metrics::workspaced_service(),
|
||||
|
||||
@@ -1,195 +1,11 @@
|
||||
<script module lang="ts">
|
||||
import type {
|
||||
TableEditorValues,
|
||||
TableEditorValuesColumn,
|
||||
TableEditorForeignKey
|
||||
} from '$lib/components/apps/components/display/dbtable/tableEditor'
|
||||
import {
|
||||
diffTableEditorValues,
|
||||
type AlterTableValues,
|
||||
makeAlterTableQueries
|
||||
} from '$lib/components/apps/components/display/dbtable/queries/alterTable'
|
||||
import { renderForeignKey } from '$lib/components/apps/components/display/dbtable/queries/dbQueriesUtils'
|
||||
import type { GetDatatableFullSchemaResponse } from '$lib/gen'
|
||||
|
||||
export type DatabaseSchema = Record<string, Record<string, TableEditorValues>>
|
||||
|
||||
export function apiSchemaToEditorSchema(
|
||||
apiSchema: GetDatatableFullSchemaResponse
|
||||
): DatabaseSchema {
|
||||
const result: DatabaseSchema = {}
|
||||
for (const [schemaName, tables] of Object.entries(apiSchema)) {
|
||||
result[schemaName] = {}
|
||||
for (const [tableName, table] of Object.entries(tables as Record<string, any>)) {
|
||||
if (!table || typeof table !== 'object') continue
|
||||
result[schemaName][tableName] = {
|
||||
name: table.name ?? tableName,
|
||||
columns: (table.columns ?? []).map(
|
||||
(c: any): TableEditorValuesColumn => ({
|
||||
name: c.name,
|
||||
datatype: c.datatype,
|
||||
primaryKey: c.primary_key ?? c.primaryKey,
|
||||
defaultValue: c.default_value ?? c.defaultValue,
|
||||
nullable: c.nullable
|
||||
})
|
||||
),
|
||||
foreignKeys: (table.foreign_keys ?? table.foreignKeys ?? []).map(
|
||||
(fk: any): TableEditorForeignKey => ({
|
||||
targetTable: fk.target_table ?? fk.targetTable,
|
||||
columns: (fk.columns ?? []).map((col: any) => ({
|
||||
sourceColumn: col.source_column ?? col.sourceColumn,
|
||||
targetColumn: col.target_column ?? col.targetColumn
|
||||
})),
|
||||
onDelete: (fk.on_delete ?? fk.onDelete ?? 'NO ACTION') as
|
||||
| 'CASCADE'
|
||||
| 'SET NULL'
|
||||
| 'NO ACTION',
|
||||
onUpdate: (fk.on_update ?? fk.onUpdate ?? 'NO ACTION') as
|
||||
| 'CASCADE'
|
||||
| 'SET NULL'
|
||||
| 'NO ACTION',
|
||||
fk_constraint_name: fk.fk_constraint_name
|
||||
})
|
||||
),
|
||||
pk_constraint_name: table.pk_constraint_name
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export type TableDiff = {
|
||||
schemaName: string
|
||||
tableName: string
|
||||
kind: 'added' | 'removed' | 'modified'
|
||||
operations?: AlterTableValues
|
||||
}
|
||||
|
||||
export type DatatableDiff = {
|
||||
datatableName: string
|
||||
aheadChanges: TableDiff[]
|
||||
behindChanges: TableDiff[]
|
||||
originalSchema: DatabaseSchema
|
||||
parentSchema: DatabaseSchema
|
||||
forkSchema: DatabaseSchema
|
||||
}
|
||||
|
||||
export function diffDatabaseSchemas(
|
||||
original: DatabaseSchema,
|
||||
current: DatabaseSchema
|
||||
): TableDiff[] {
|
||||
const diffs: TableDiff[] = []
|
||||
const allSchemas = new Set([...Object.keys(original), ...Object.keys(current)])
|
||||
for (const schemaName of allSchemas) {
|
||||
const origTables = original[schemaName] ?? {}
|
||||
const currTables = current[schemaName] ?? {}
|
||||
const allTables = new Set([...Object.keys(origTables), ...Object.keys(currTables)])
|
||||
for (const tableName of allTables) {
|
||||
const origTable = origTables[tableName]
|
||||
const currTable = currTables[tableName]
|
||||
if (!origTable && currTable) {
|
||||
diffs.push({ schemaName, tableName, kind: 'added' })
|
||||
} else if (origTable && !currTable) {
|
||||
diffs.push({ schemaName, tableName, kind: 'removed' })
|
||||
} else if (origTable && currTable) {
|
||||
const currWithInitial: TableEditorValues = {
|
||||
...currTable,
|
||||
columns: currTable.columns.map((col) => ({
|
||||
...col,
|
||||
initialName: col.name,
|
||||
defaultValue: col.defaultValue ? `{${col.defaultValue}}` : undefined
|
||||
}))
|
||||
}
|
||||
const origTableTransformed: TableEditorValues = {
|
||||
...origTable,
|
||||
columns: origTable.columns.map((col) => ({
|
||||
...col,
|
||||
defaultValue: col.defaultValue ? `{${col.defaultValue}}` : undefined
|
||||
}))
|
||||
}
|
||||
const diff = diffTableEditorValues(origTableTransformed, currWithInitial)
|
||||
if (diff.operations.length > 0) {
|
||||
diffs.push({ schemaName, tableName, kind: 'modified', operations: diff })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return diffs
|
||||
}
|
||||
|
||||
export function computeDatatableDiff(
|
||||
datatableName: string,
|
||||
originalSchema: DatabaseSchema,
|
||||
parentSchema: DatabaseSchema,
|
||||
forkSchema: DatabaseSchema
|
||||
): DatatableDiff {
|
||||
return {
|
||||
datatableName,
|
||||
behindChanges: diffDatabaseSchemas(originalSchema, parentSchema),
|
||||
aheadChanges: diffDatabaseSchemas(originalSchema, forkSchema),
|
||||
originalSchema,
|
||||
parentSchema,
|
||||
forkSchema
|
||||
}
|
||||
}
|
||||
|
||||
/** Detect PostgreSQL auto-increment columns and return the serial type + cleaned props.
|
||||
* e.g. bigint + nextval('seq'::regclass) → BIGSERIAL (no DEFAULT needed) */
|
||||
function resolveColumnType(c: TableEditorValuesColumn): {
|
||||
datatype: string
|
||||
defaultValue: string | undefined
|
||||
} {
|
||||
const dv = c.defaultValue ?? ''
|
||||
if (/^{?nextval\(/.test(dv)) {
|
||||
const dt = c.datatype?.toLowerCase() ?? ''
|
||||
if (dt === 'bigint') return { datatype: 'BIGSERIAL', defaultValue: undefined }
|
||||
if (dt === 'integer' || dt === 'int') return { datatype: 'SERIAL', defaultValue: undefined }
|
||||
if (dt === 'smallint') return { datatype: 'SMALLSERIAL', defaultValue: undefined }
|
||||
}
|
||||
return { datatype: c.datatype, defaultValue: c.defaultValue }
|
||||
}
|
||||
|
||||
export function generateMigrationSql(change: TableDiff, sourceSchema: DatabaseSchema): string {
|
||||
if (change.kind === 'modified' && change.operations) {
|
||||
const queries = makeAlterTableQueries(change.operations, 'postgresql', change.schemaName)
|
||||
if (queries.length === 0) return ''
|
||||
return 'BEGIN;\n' + queries.join('\n') + '\nCOMMIT;'
|
||||
}
|
||||
if (change.kind === 'added') {
|
||||
const table = sourceSchema[change.schemaName]?.[change.tableName]
|
||||
if (!table) return ''
|
||||
const colDefs = table.columns
|
||||
.map((c) => {
|
||||
const { datatype, defaultValue } = resolveColumnType(c)
|
||||
let def = `"${c.name}" ${datatype}`
|
||||
if (c.nullable === false) def += ' NOT NULL'
|
||||
if (defaultValue) def += ` DEFAULT ${defaultValue}`
|
||||
return def
|
||||
})
|
||||
.join(',\n ')
|
||||
const pkCols = table.columns.filter((c) => c.primaryKey).map((c) => `"${c.name}"`)
|
||||
const pkLine = pkCols.length > 0 ? `,\n PRIMARY KEY (${pkCols.join(', ')})` : ''
|
||||
const qualifiedName = `"${change.schemaName}"."${change.tableName}"`
|
||||
let sql = `BEGIN;\nCREATE TABLE ${qualifiedName} (\n ${colDefs}${pkLine}\n);`
|
||||
for (const fk of table.foreignKeys ?? []) {
|
||||
const fkSql = renderForeignKey(fk, {
|
||||
useSchema: true,
|
||||
dbType: 'postgresql',
|
||||
tableName: change.tableName
|
||||
})
|
||||
sql += `\nALTER TABLE ${qualifiedName} ADD ${fkSql};`
|
||||
}
|
||||
sql += '\nCOMMIT;'
|
||||
return sql
|
||||
}
|
||||
if (change.kind === 'removed') {
|
||||
return `BEGIN;\nDROP TABLE IF EXISTS "${change.schemaName}"."${change.tableName}";\nCOMMIT;`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
apiSchemaToEditorSchema,
|
||||
computeDatatableDiff,
|
||||
generateMigrationSql,
|
||||
type DatatableDiff,
|
||||
type TableDiff
|
||||
} from './datatableSchemaSql'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { Loader2, ChevronDown, ChevronRight, Plus, Minus, Pencil, Eye } from 'lucide-svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
deploymentStatus: Record<string, { status: 'loading' | 'deployed' | 'failed'; error?: string }>
|
||||
allSelected?: boolean
|
||||
emptyMessage?: string
|
||||
hideSelection?: boolean
|
||||
children?: Snippet
|
||||
|
||||
// Snippets for customization
|
||||
@@ -64,6 +65,7 @@
|
||||
deploymentStatus,
|
||||
allSelected = false,
|
||||
emptyMessage = 'No items to deploy',
|
||||
hideSelection = false,
|
||||
header,
|
||||
alerts,
|
||||
selectAllActions,
|
||||
@@ -150,12 +152,13 @@
|
||||
{@render alerts()}
|
||||
{/if}
|
||||
|
||||
<!-- Controls row: "Select all" (when there are items) + optional right-side
|
||||
actions (e.g. a filter toggle). Renders when there are items OR actions are
|
||||
provided, so a filter that empties the list doesn't take its own toggle with it. -->
|
||||
<!-- Controls row: "Select all" (when there are items and selection is enabled) +
|
||||
optional right-side actions (e.g. a filter toggle). Renders when there are
|
||||
items OR actions are provided, so a filter that empties the list doesn't take
|
||||
its own toggle with it. -->
|
||||
{#if items.length > 0 || selectAllActions}
|
||||
<div class="px-4 py-2 flex items-center justify-between">
|
||||
{#if items.length > 0}
|
||||
{#if items.length > 0 && !hideSelection}
|
||||
<label
|
||||
class="flex items-center gap-2 text-secondary text-xs"
|
||||
class:opacity-50={!hasSelectableItems}
|
||||
@@ -190,15 +193,17 @@
|
||||
class="sticky top-0 z-10 flex items-center gap-2 px-4 py-1.5 bg-surface-secondary border-b first:rounded-t-md"
|
||||
title={selectable.length === 0 ? 'No selectable items in this group' : undefined}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectable.length > 0 && selectedCount === selectable.length}
|
||||
indeterminate={selectedCount > 0 && selectedCount < selectable.length}
|
||||
disabled={selectable.length === 0}
|
||||
title={selectedCount === selectable.length
|
||||
? `Deselect all in ${group.label}`
|
||||
: `Select all in ${group.label}`}
|
||||
onChange={() => toggleGroup(group)}
|
||||
/>
|
||||
{#if !hideSelection}
|
||||
<Checkbox
|
||||
checked={selectable.length > 0 && selectedCount === selectable.length}
|
||||
indeterminate={selectedCount > 0 && selectedCount < selectable.length}
|
||||
disabled={selectable.length === 0}
|
||||
title={selectedCount === selectable.length
|
||||
? `Deselect all in ${group.label}`
|
||||
: `Select all in ${group.label}`}
|
||||
onChange={() => toggleGroup(group)}
|
||||
/>
|
||||
{/if}
|
||||
{#if group.groupKind === 'folder'}
|
||||
<Folder size={14} class="text-tertiary shrink-0" />
|
||||
{:else if group.groupKind === 'user'}
|
||||
@@ -234,11 +239,11 @@
|
||||
: item.path}
|
||||
|
||||
<Row
|
||||
isSelectable={isSelectable && !isDeployed}
|
||||
isSelectable={!hideSelection && isSelectable && !isDeployed}
|
||||
selectDisabledReason={blockedReason}
|
||||
selectOnRowClick={true}
|
||||
alignWithSelectable={true}
|
||||
disabled={blockedReason ? false : !isSelectable}
|
||||
selectOnRowClick={!hideSelection}
|
||||
alignWithSelectable={!hideSelection}
|
||||
disabled={!hideSelection && (blockedReason ? false : !isSelectable)}
|
||||
selected={isSelected && !isDeployed}
|
||||
onSelect={() => handleSelect(item)}
|
||||
path={showPath ? item.path : ''}
|
||||
|
||||
@@ -493,6 +493,10 @@
|
||||
// restores its input. Guarded on the path so a staging round-trip
|
||||
// (emit → page → runFormInitialArgs) doesn't re-seed and loop. The read-only
|
||||
// branch uses PipelineScriptView's own onArgsChange instead.
|
||||
// Declared before the pre-effect that seeds it: a `$state` referenced by an
|
||||
// earlier-registered `$effect.pre` hits a TDZ ("Cannot access 'args' before
|
||||
// initialization") when the pane remounts and the pre-effect runs before this
|
||||
// line executes.
|
||||
let args = $state<Record<string, any>>({})
|
||||
let argsSeedPath: string | undefined = undefined
|
||||
$effect.pre(() => {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import type {
|
||||
TableEditorValues,
|
||||
TableEditorValuesColumn,
|
||||
TableEditorForeignKey
|
||||
} from '$lib/components/apps/components/display/dbtable/tableEditor'
|
||||
import {
|
||||
diffTableEditorValues,
|
||||
type AlterTableValues,
|
||||
makeAlterTableQueries
|
||||
} from '$lib/components/apps/components/display/dbtable/queries/alterTable'
|
||||
import { renderForeignKey } from '$lib/components/apps/components/display/dbtable/queries/dbQueriesUtils'
|
||||
import type { GetDatatableFullSchemaResponse } from '$lib/gen'
|
||||
|
||||
export type DatabaseSchema = Record<string, Record<string, TableEditorValues>>
|
||||
|
||||
export function apiSchemaToEditorSchema(apiSchema: GetDatatableFullSchemaResponse): DatabaseSchema {
|
||||
const result: DatabaseSchema = {}
|
||||
for (const [schemaName, tables] of Object.entries(apiSchema)) {
|
||||
result[schemaName] = {}
|
||||
for (const [tableName, table] of Object.entries(tables as Record<string, any>)) {
|
||||
if (!table || typeof table !== 'object') continue
|
||||
result[schemaName][tableName] = {
|
||||
name: table.name ?? tableName,
|
||||
columns: (table.columns ?? []).map(
|
||||
(c: any): TableEditorValuesColumn => ({
|
||||
name: c.name,
|
||||
datatype: c.datatype,
|
||||
primaryKey: c.primary_key ?? c.primaryKey,
|
||||
defaultValue: c.default_value ?? c.defaultValue,
|
||||
nullable: c.nullable
|
||||
})
|
||||
),
|
||||
foreignKeys: (table.foreign_keys ?? table.foreignKeys ?? []).map(
|
||||
(fk: any): TableEditorForeignKey => ({
|
||||
targetTable: fk.target_table ?? fk.targetTable,
|
||||
columns: (fk.columns ?? []).map((col: any) => ({
|
||||
sourceColumn: col.source_column ?? col.sourceColumn,
|
||||
targetColumn: col.target_column ?? col.targetColumn
|
||||
})),
|
||||
onDelete: (fk.on_delete ?? fk.onDelete ?? 'NO ACTION') as
|
||||
| 'CASCADE'
|
||||
| 'SET NULL'
|
||||
| 'NO ACTION',
|
||||
onUpdate: (fk.on_update ?? fk.onUpdate ?? 'NO ACTION') as
|
||||
| 'CASCADE'
|
||||
| 'SET NULL'
|
||||
| 'NO ACTION',
|
||||
fk_constraint_name: fk.fk_constraint_name
|
||||
})
|
||||
),
|
||||
pk_constraint_name: table.pk_constraint_name
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export type TableDiff = {
|
||||
schemaName: string
|
||||
tableName: string
|
||||
kind: 'added' | 'removed' | 'modified'
|
||||
operations?: AlterTableValues
|
||||
}
|
||||
|
||||
export type DatatableDiff = {
|
||||
datatableName: string
|
||||
aheadChanges: TableDiff[]
|
||||
behindChanges: TableDiff[]
|
||||
originalSchema: DatabaseSchema
|
||||
parentSchema: DatabaseSchema
|
||||
forkSchema: DatabaseSchema
|
||||
}
|
||||
|
||||
export function diffDatabaseSchemas(
|
||||
original: DatabaseSchema,
|
||||
current: DatabaseSchema
|
||||
): TableDiff[] {
|
||||
const diffs: TableDiff[] = []
|
||||
const allSchemas = new Set([...Object.keys(original), ...Object.keys(current)])
|
||||
for (const schemaName of allSchemas) {
|
||||
const origTables = original[schemaName] ?? {}
|
||||
const currTables = current[schemaName] ?? {}
|
||||
const allTables = new Set([...Object.keys(origTables), ...Object.keys(currTables)])
|
||||
for (const tableName of allTables) {
|
||||
const origTable = origTables[tableName]
|
||||
const currTable = currTables[tableName]
|
||||
if (!origTable && currTable) {
|
||||
diffs.push({ schemaName, tableName, kind: 'added' })
|
||||
} else if (origTable && !currTable) {
|
||||
diffs.push({ schemaName, tableName, kind: 'removed' })
|
||||
} else if (origTable && currTable) {
|
||||
const currWithInitial: TableEditorValues = {
|
||||
...currTable,
|
||||
columns: currTable.columns.map((col) => ({
|
||||
...col,
|
||||
initialName: col.name,
|
||||
defaultValue: col.defaultValue ? `{${col.defaultValue}}` : undefined
|
||||
}))
|
||||
}
|
||||
const origTableTransformed: TableEditorValues = {
|
||||
...origTable,
|
||||
columns: origTable.columns.map((col) => ({
|
||||
...col,
|
||||
defaultValue: col.defaultValue ? `{${col.defaultValue}}` : undefined
|
||||
}))
|
||||
}
|
||||
const diff = diffTableEditorValues(origTableTransformed, currWithInitial)
|
||||
if (diff.operations.length > 0) {
|
||||
diffs.push({ schemaName, tableName, kind: 'modified', operations: diff })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return diffs
|
||||
}
|
||||
|
||||
export function computeDatatableDiff(
|
||||
datatableName: string,
|
||||
originalSchema: DatabaseSchema,
|
||||
parentSchema: DatabaseSchema,
|
||||
forkSchema: DatabaseSchema
|
||||
): DatatableDiff {
|
||||
return {
|
||||
datatableName,
|
||||
behindChanges: diffDatabaseSchemas(originalSchema, parentSchema),
|
||||
aheadChanges: diffDatabaseSchemas(originalSchema, forkSchema),
|
||||
originalSchema,
|
||||
parentSchema,
|
||||
forkSchema
|
||||
}
|
||||
}
|
||||
|
||||
/** Detect PostgreSQL auto-increment columns and return the serial type + cleaned props.
|
||||
* e.g. bigint + nextval('seq'::regclass) → BIGSERIAL (no DEFAULT needed) */
|
||||
function resolveColumnType(c: TableEditorValuesColumn): {
|
||||
datatype: string
|
||||
defaultValue: string | undefined
|
||||
} {
|
||||
const dv = c.defaultValue ?? ''
|
||||
if (/^{?nextval\(/.test(dv)) {
|
||||
const dt = c.datatype?.toLowerCase() ?? ''
|
||||
if (dt === 'bigint') return { datatype: 'BIGSERIAL', defaultValue: undefined }
|
||||
if (dt === 'integer' || dt === 'int') return { datatype: 'SERIAL', defaultValue: undefined }
|
||||
if (dt === 'smallint') return { datatype: 'SMALLSERIAL', defaultValue: undefined }
|
||||
}
|
||||
return { datatype: c.datatype, defaultValue: c.defaultValue }
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL for an added table, with the CREATE TABLE and the FK constraints split so
|
||||
* callers creating several tables can emit every CREATE before any constraint —
|
||||
* required for circular FKs, where no creation order satisfies inline FKs.
|
||||
*/
|
||||
export function generateAddedTableSql(
|
||||
change: TableDiff,
|
||||
sourceSchema: DatabaseSchema,
|
||||
options?: { ifNotExists?: boolean }
|
||||
): { create: string; constraints: string[] } | undefined {
|
||||
const table = sourceSchema[change.schemaName]?.[change.tableName]
|
||||
if (!table) return undefined
|
||||
const colDefs = table.columns
|
||||
.map((c) => {
|
||||
const { datatype, defaultValue } = resolveColumnType(c)
|
||||
let def = `"${c.name}" ${datatype}`
|
||||
if (c.nullable === false) def += ' NOT NULL'
|
||||
if (defaultValue) def += ` DEFAULT ${defaultValue}`
|
||||
return def
|
||||
})
|
||||
.join(',\n ')
|
||||
const pkCols = table.columns.filter((c) => c.primaryKey).map((c) => `"${c.name}"`)
|
||||
const pkLine = pkCols.length > 0 ? `,\n PRIMARY KEY (${pkCols.join(', ')})` : ''
|
||||
const qualifiedName = `"${change.schemaName}"."${change.tableName}"`
|
||||
const createKeyword = options?.ifNotExists ? 'CREATE TABLE IF NOT EXISTS' : 'CREATE TABLE'
|
||||
// The target may not have the schema at all (fresh data table import).
|
||||
const schemaDdl =
|
||||
change.schemaName !== 'public' ? `CREATE SCHEMA IF NOT EXISTS "${change.schemaName}";\n` : ''
|
||||
const create = `${schemaDdl}${createKeyword} ${qualifiedName} (\n ${colDefs}${pkLine}\n);`
|
||||
const constraints: string[] = []
|
||||
for (const fk of table.foreignKeys ?? []) {
|
||||
const fkSql = renderForeignKey(fk, {
|
||||
useSchema: true,
|
||||
dbType: 'postgresql',
|
||||
tableName: change.tableName
|
||||
})
|
||||
// With IF NOT EXISTS the table may pre-exist with this FK already in
|
||||
// place; an unconditional ADD would then abort the whole transaction.
|
||||
// The constraint name is emitted unquoted, so Postgres folds it to
|
||||
// lowercase — compare against the folded form.
|
||||
const fkName = options?.ifNotExists
|
||||
? fkSql.match(/^CONSTRAINT\s+(\S+)/)?.[1]?.toLowerCase()
|
||||
: undefined
|
||||
constraints.push(
|
||||
fkName
|
||||
? `DO $$\nBEGIN\n IF NOT EXISTS (\n SELECT 1 FROM pg_constraint\n WHERE conname = '${fkName}' AND conrelid = '${qualifiedName}'::regclass\n ) THEN\n ALTER TABLE ${qualifiedName} ADD ${fkSql};\n END IF;\nEND $$;`
|
||||
: `ALTER TABLE ${qualifiedName} ADD ${fkSql};`
|
||||
)
|
||||
}
|
||||
return { create, constraints }
|
||||
}
|
||||
|
||||
export function generateMigrationSql(
|
||||
change: TableDiff,
|
||||
sourceSchema: DatabaseSchema,
|
||||
options?: { ifNotExists?: boolean }
|
||||
): string {
|
||||
if (change.kind === 'modified' && change.operations) {
|
||||
const queries = makeAlterTableQueries(change.operations, 'postgresql', change.schemaName)
|
||||
if (queries.length === 0) return ''
|
||||
return 'BEGIN;\n' + queries.join('\n') + '\nCOMMIT;'
|
||||
}
|
||||
if (change.kind === 'added') {
|
||||
const gen = generateAddedTableSql(change, sourceSchema, options)
|
||||
if (!gen) return ''
|
||||
return `BEGIN;\n${[gen.create, ...gen.constraints].join('\n')}\nCOMMIT;`
|
||||
}
|
||||
if (change.kind === 'removed') {
|
||||
return `BEGIN;\nDROP TABLE IF EXISTS "${change.schemaName}"."${change.tableName}";\nCOMMIT;`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -102,7 +102,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
loadAvailableNativeTriggers()
|
||||
// Native triggers are EE-only; on CE the `/exists` checks just 404. Gate the
|
||||
// load by license (reactive, since the license loads asynchronously).
|
||||
$effect(() => {
|
||||
if ($enterpriseLicense && $workspaceStore) {
|
||||
loadAvailableNativeTriggers()
|
||||
}
|
||||
})
|
||||
|
||||
const triggersCollapsed = useLocalStorageValue(
|
||||
'windmill_triggers_section_collapsed',
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
portableTriggerConfig,
|
||||
triggerHandlerRefs,
|
||||
type WorkspaceTrigger
|
||||
} from './workspaceTriggersList'
|
||||
|
||||
const trigger = (kind: string, config: Record<string, unknown>): WorkspaceTrigger =>
|
||||
({ kind, path: 'f/p/t', script_path: 'f/p/s', is_flow: false, config }) as WorkspaceTrigger
|
||||
|
||||
describe('portableTriggerConfig', () => {
|
||||
it('is an allowlist: unknown and instance-side fields never cross the boundary', () => {
|
||||
const out = portableTriggerConfig('schedule', {
|
||||
// portable
|
||||
schedule: '0 0 * * * *',
|
||||
cron_version: 'v2',
|
||||
no_flow_overlap: true,
|
||||
on_failure: 'script/f/p/handler',
|
||||
dynamic_skip: 'f/p/skip',
|
||||
retry: { constant: { attempts: 1 } },
|
||||
// instance-side / identity — must all be dropped
|
||||
email: 'owner@corp.com',
|
||||
edited_by: 'admin',
|
||||
is_draft: false,
|
||||
paused_until: '2026-01-01',
|
||||
enabled: true,
|
||||
workspace_id: 'ws',
|
||||
extra_perms: {},
|
||||
permissioned_as: 'u/admin',
|
||||
tag: 'gpu-worker',
|
||||
// a field upstream might add tomorrow — dropped until admitted
|
||||
some_future_field: 'x'
|
||||
})
|
||||
expect(Object.keys(out).sort()).toEqual([
|
||||
'cron_version',
|
||||
'dynamic_skip',
|
||||
'no_flow_overlap',
|
||||
'on_failure',
|
||||
'retry',
|
||||
'schedule'
|
||||
])
|
||||
})
|
||||
|
||||
it('appends shared behavior fields for non-schedule kinds', () => {
|
||||
const out = portableTriggerConfig('mqtt', {
|
||||
mqtt_resource_path: 'f/p/broker',
|
||||
error_handler_path: 'f/p/handler',
|
||||
retry: {},
|
||||
permissioned_as: 'u/admin',
|
||||
server_id: 'srv-1'
|
||||
})
|
||||
expect(Object.keys(out).sort()).toEqual(['error_handler_path', 'mqtt_resource_path', 'retry'])
|
||||
})
|
||||
|
||||
it('returns empty for unknown kinds or missing config', () => {
|
||||
expect(portableTriggerConfig('nope', { a: 1 })).toEqual({})
|
||||
expect(portableTriggerConfig('mqtt', null)).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('triggerHandlerRefs', () => {
|
||||
it('collects schedule handlers including dynamic_skip', () => {
|
||||
expect(
|
||||
triggerHandlerRefs(
|
||||
trigger('schedule', {
|
||||
on_failure: 'script/f/p/fail',
|
||||
on_recovery: 'flow/f/p/recover',
|
||||
dynamic_skip: 'f/p/skip'
|
||||
})
|
||||
)
|
||||
).toEqual([
|
||||
{ kind: 'script', path: 'f/p/fail' },
|
||||
{ kind: 'flow', path: 'f/p/recover' },
|
||||
{ kind: 'script', path: 'f/p/skip' }
|
||||
])
|
||||
})
|
||||
|
||||
it('collects bare error_handler_path for non-schedule kinds', () => {
|
||||
expect(triggerHandlerRefs(trigger('mqtt', { error_handler_path: 'u/admin/handler' }))).toEqual([
|
||||
{ kind: 'script', path: 'u/admin/handler' }
|
||||
])
|
||||
})
|
||||
|
||||
it('collects websocket runnable url and initial-message runnables', () => {
|
||||
expect(
|
||||
triggerHandlerRefs(
|
||||
trigger('websocket', {
|
||||
url: '$script:f/p/url_builder',
|
||||
initial_messages: [
|
||||
{ raw_message: 'hi' },
|
||||
{ runnable_result: { path: 'f/p/greeter', args: {}, is_flow: true } }
|
||||
]
|
||||
})
|
||||
)
|
||||
).toEqual([
|
||||
{ kind: 'script', path: 'f/p/url_builder' },
|
||||
{ kind: 'flow', path: 'f/p/greeter' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('triggerHandlerRefs edge cases', () => {
|
||||
it('returns empty when no handlers are configured', () => {
|
||||
expect(triggerHandlerRefs(trigger('schedule', { schedule: '0 0 * * * *', timezone: 'UTC' }))).toEqual([])
|
||||
expect(triggerHandlerRefs(trigger('mqtt', { mqtt_resource_path: 'f/p/broker' }))).toEqual([])
|
||||
})
|
||||
|
||||
it('collects on_success handlers', () => {
|
||||
expect(triggerHandlerRefs(trigger('schedule', { on_success: 'script/f/p/notify' }))).toEqual([
|
||||
{ kind: 'script', path: 'f/p/notify' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,562 @@
|
||||
import {
|
||||
HttpTriggerService,
|
||||
WebsocketTriggerService,
|
||||
KafkaTriggerService,
|
||||
NatsTriggerService,
|
||||
SqsTriggerService,
|
||||
MqttTriggerService,
|
||||
AmqpTriggerService,
|
||||
GcpTriggerService,
|
||||
AzureTriggerService,
|
||||
PostgresTriggerService,
|
||||
EmailTriggerService,
|
||||
ScheduleService
|
||||
} from '$lib/gen'
|
||||
|
||||
export type WorkspaceTriggerKind =
|
||||
| 'http'
|
||||
| 'websocket'
|
||||
| 'schedule'
|
||||
| 'kafka'
|
||||
| 'nats'
|
||||
| 'sqs'
|
||||
| 'mqtt'
|
||||
| 'amqp'
|
||||
| 'gcp'
|
||||
| 'azure'
|
||||
| 'postgres'
|
||||
| 'email'
|
||||
|
||||
/** One trigger row, normalized across kinds; `config` keeps the raw API row. */
|
||||
export interface WorkspaceTrigger {
|
||||
kind: WorkspaceTriggerKind
|
||||
path: string
|
||||
script_path: string
|
||||
is_flow: boolean
|
||||
summary?: string
|
||||
config: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for per-kind trigger knowledge shared by surfaces that
|
||||
* handle every trigger kind (Hub publish/install, exports): display badge, the
|
||||
* workspace list route, an optional post-import caveat, the config field
|
||||
* holding the kind's resource path, whether the kind requires an EE license
|
||||
* (its endpoints 404 on CE), and the list/create calls. Schedules have no
|
||||
* `create` entry: they disable via `enabled` (not `mode`) and use a dedicated
|
||||
* body shape, handled in `createWorkspaceTriggerDisabled`.
|
||||
*/
|
||||
export const TRIGGER_KINDS: Record<
|
||||
WorkspaceTriggerKind,
|
||||
{
|
||||
badge: string
|
||||
route: string
|
||||
note?: string
|
||||
resourceField?: string
|
||||
eeOnly?: boolean
|
||||
/**
|
||||
* Creating a trigger of this kind manages external cloud state (e.g.
|
||||
* Pub/Sub or Event Grid subscriptions) before storing it, even with
|
||||
* `mode: 'disabled'` — so it must never be auto-created from an import.
|
||||
*/
|
||||
provisionsOnCreate?: boolean
|
||||
/**
|
||||
* The kind-specific config fields that cross the workspace boundary
|
||||
* (export to the Hub AND import from it) — an allowlist, so a field
|
||||
* added upstream is dropped until someone consciously admits it here,
|
||||
* instead of leaking to the Hub or being injectable from a crafted
|
||||
* export. Identity/ownership/runtime fields (path, permissioned_as,
|
||||
* email, enabled, server_id, provisioned subscription ids, …) must
|
||||
* never be listed. `tag` (schedules) is excluded deliberately: it names
|
||||
* a worker group of the source instance, and a foreign tag makes
|
||||
* imported jobs queue forever on a tag no worker serves. Non-schedule
|
||||
* kinds get the shared behavior fields (error handler, retry) appended
|
||||
* by `portableTriggerConfig`.
|
||||
*/
|
||||
configFields: string[]
|
||||
list: (
|
||||
workspace: string,
|
||||
onError?: (message: string) => void
|
||||
) => Promise<Array<Record<string, any>>>
|
||||
create?: (workspace: string, requestBody: any) => Promise<unknown>
|
||||
}
|
||||
> = {
|
||||
http: {
|
||||
configFields: [
|
||||
'route_path',
|
||||
'workspaced_route',
|
||||
'http_method',
|
||||
'authentication_resource_path',
|
||||
'is_async',
|
||||
'request_type',
|
||||
'authentication_method',
|
||||
'is_static_website',
|
||||
'static_asset_config',
|
||||
'wrap_body',
|
||||
'raw_string'
|
||||
],
|
||||
badge: 'HTTP',
|
||||
route: 'routes',
|
||||
note: 'Webhook URL regenerates on import — re-register with the external service.',
|
||||
resourceField: 'authentication_resource_path',
|
||||
list: (workspace) => HttpTriggerService.listHttpTriggers({ workspace }),
|
||||
create: (workspace, requestBody) =>
|
||||
HttpTriggerService.createHttpTrigger({ workspace, requestBody })
|
||||
},
|
||||
websocket: {
|
||||
configFields: [
|
||||
'url',
|
||||
'filters',
|
||||
'filter_logic',
|
||||
'initial_messages',
|
||||
'url_runnable_args',
|
||||
'can_return_message',
|
||||
'can_return_error_result',
|
||||
'heartbeat'
|
||||
],
|
||||
badge: 'WebSocket',
|
||||
route: 'websocket_triggers',
|
||||
note: 'Reconnect WebSocket auth after import if external service requires it.',
|
||||
list: (workspace) => WebsocketTriggerService.listWebsocketTriggers({ workspace }),
|
||||
create: (workspace, requestBody) =>
|
||||
WebsocketTriggerService.createWebsocketTrigger({ workspace, requestBody })
|
||||
},
|
||||
schedule: {
|
||||
configFields: [
|
||||
'schedule',
|
||||
'timezone',
|
||||
'args',
|
||||
'on_failure',
|
||||
'on_failure_times',
|
||||
'on_failure_exact',
|
||||
'on_failure_extra_args',
|
||||
'on_recovery',
|
||||
'on_recovery_times',
|
||||
'on_recovery_extra_args',
|
||||
'on_success',
|
||||
'on_success_extra_args',
|
||||
'ws_error_handler_muted',
|
||||
'retry',
|
||||
'no_flow_overlap',
|
||||
'cron_version',
|
||||
'dynamic_skip'
|
||||
],
|
||||
badge: 'Schedule',
|
||||
route: 'schedules',
|
||||
// listSchedules returns slim rows (no args, handlers, cron_version, retry,
|
||||
// no_flow_overlap) — resolve each to the full schedule so exported configs
|
||||
// are complete. A schedule whose detail fetch fails is excluded and
|
||||
// reported: exporting the slim row would silently publish a schedule with
|
||||
// default behavior instead of its real settings.
|
||||
list: async (workspace, onError) => {
|
||||
const rows = await ScheduleService.listSchedules({ workspace })
|
||||
const full = await Promise.all(
|
||||
rows.map((r) =>
|
||||
ScheduleService.getSchedule({ workspace, path: r.path }).catch((e: any) => {
|
||||
onError?.(
|
||||
`Failed to load schedule ${r.path} (${e?.message ?? e}) — excluded from the project`
|
||||
)
|
||||
return undefined
|
||||
})
|
||||
)
|
||||
)
|
||||
return full.filter((r): r is NonNullable<typeof r> => r !== undefined)
|
||||
}
|
||||
},
|
||||
kafka: {
|
||||
configFields: [
|
||||
'kafka_resource_path',
|
||||
'group_id',
|
||||
'topics',
|
||||
'filters',
|
||||
'filter_logic',
|
||||
'auto_offset_reset',
|
||||
'auto_commit'
|
||||
],
|
||||
badge: 'Kafka',
|
||||
route: 'kafka_triggers',
|
||||
note: 'Verify Kafka broker access from the importing instance.',
|
||||
resourceField: 'kafka_resource_path',
|
||||
eeOnly: true,
|
||||
list: (workspace) => KafkaTriggerService.listKafkaTriggers({ workspace }),
|
||||
create: (workspace, requestBody) =>
|
||||
KafkaTriggerService.createKafkaTrigger({ workspace, requestBody })
|
||||
},
|
||||
nats: {
|
||||
configFields: [
|
||||
'nats_resource_path',
|
||||
'use_jetstream',
|
||||
'stream_name',
|
||||
'consumer_name',
|
||||
'subjects'
|
||||
],
|
||||
badge: 'NATS',
|
||||
route: 'nats_triggers',
|
||||
note: 'Verify NATS connection from the importing instance.',
|
||||
resourceField: 'nats_resource_path',
|
||||
eeOnly: true,
|
||||
list: (workspace) => NatsTriggerService.listNatsTriggers({ workspace }),
|
||||
create: (workspace, requestBody) =>
|
||||
NatsTriggerService.createNatsTrigger({ workspace, requestBody })
|
||||
},
|
||||
sqs: {
|
||||
configFields: [
|
||||
'queue_url',
|
||||
'aws_auth_resource_type',
|
||||
'aws_resource_path',
|
||||
'message_attributes'
|
||||
],
|
||||
badge: 'SQS',
|
||||
route: 'sqs_triggers',
|
||||
resourceField: 'aws_resource_path',
|
||||
eeOnly: true,
|
||||
list: (workspace) => SqsTriggerService.listSqsTriggers({ workspace }),
|
||||
create: (workspace, requestBody) =>
|
||||
SqsTriggerService.createSqsTrigger({ workspace, requestBody })
|
||||
},
|
||||
mqtt: {
|
||||
configFields: [
|
||||
'mqtt_resource_path',
|
||||
'subscribe_topics',
|
||||
'client_id',
|
||||
'v3_config',
|
||||
'v5_config',
|
||||
'client_version'
|
||||
],
|
||||
badge: 'MQTT',
|
||||
route: 'mqtt_triggers',
|
||||
resourceField: 'mqtt_resource_path',
|
||||
list: (workspace) => MqttTriggerService.listMqttTriggers({ workspace }),
|
||||
create: (workspace, requestBody) =>
|
||||
MqttTriggerService.createMqttTrigger({ workspace, requestBody })
|
||||
},
|
||||
amqp: {
|
||||
configFields: ['amqp_resource_path', 'queue_name', 'exchange', 'options'],
|
||||
badge: 'AMQP',
|
||||
route: 'amqp_triggers',
|
||||
note: 'Verify AMQP broker access from the importing instance.',
|
||||
resourceField: 'amqp_resource_path',
|
||||
list: (workspace) => AmqpTriggerService.listAmqpTriggers({ workspace }),
|
||||
create: (workspace, requestBody) =>
|
||||
AmqpTriggerService.createAmqpTrigger({ workspace, requestBody })
|
||||
},
|
||||
gcp: {
|
||||
configFields: ['gcp_resource_path', 'topic_id', 'delivery_type', 'subscription_mode'],
|
||||
provisionsOnCreate: true,
|
||||
badge: 'GCP Pub/Sub',
|
||||
route: 'gcp_triggers',
|
||||
note: 'Re-link GCP Pub/Sub subscription after import.',
|
||||
resourceField: 'gcp_resource_path',
|
||||
eeOnly: true,
|
||||
list: (workspace) => GcpTriggerService.listGcpTriggers({ workspace }),
|
||||
create: (workspace, requestBody) =>
|
||||
GcpTriggerService.createGcpTrigger({ workspace, requestBody })
|
||||
},
|
||||
azure: {
|
||||
configFields: [
|
||||
'azure_resource_path',
|
||||
'azure_mode',
|
||||
'scope_resource_id',
|
||||
'topic_name',
|
||||
'event_type_filters'
|
||||
],
|
||||
provisionsOnCreate: true,
|
||||
badge: 'Azure',
|
||||
route: 'azure_triggers',
|
||||
note: 'Re-link Azure Event Grid subscription after import.',
|
||||
resourceField: 'azure_resource_path',
|
||||
eeOnly: true,
|
||||
list: (workspace) => AzureTriggerService.listAzureTriggers({ workspace }),
|
||||
create: (workspace, requestBody) =>
|
||||
AzureTriggerService.createAzureTrigger({ workspace, requestBody })
|
||||
},
|
||||
postgres: {
|
||||
configFields: [
|
||||
'postgres_resource_path',
|
||||
'replication_slot_name',
|
||||
'publication_name',
|
||||
'publication'
|
||||
],
|
||||
badge: 'Postgres',
|
||||
route: 'postgres_triggers',
|
||||
resourceField: 'postgres_resource_path',
|
||||
list: (workspace) => PostgresTriggerService.listPostgresTriggers({ workspace }),
|
||||
create: (workspace, requestBody) =>
|
||||
PostgresTriggerService.createPostgresTrigger({ workspace, requestBody })
|
||||
},
|
||||
email: {
|
||||
configFields: ['local_part', 'workspaced_local_part'],
|
||||
badge: 'Email',
|
||||
route: 'email_triggers',
|
||||
note: 'Email address regenerates on import.',
|
||||
list: (workspace) => EmailTriggerService.listEmailTriggers({ workspace }),
|
||||
create: (workspace, requestBody) =>
|
||||
EmailTriggerService.createEmailTrigger({ workspace, requestBody })
|
||||
}
|
||||
}
|
||||
|
||||
export const WORKSPACE_TRIGGER_KINDS = Object.keys(TRIGGER_KINDS) as WorkspaceTriggerKind[]
|
||||
|
||||
export interface WorkspaceTriggersListing {
|
||||
triggers: WorkspaceTrigger[]
|
||||
/**
|
||||
* Kinds whose discovery failed or was incomplete. A 404 on a kind's list
|
||||
* endpoint is NOT a failure (the instance doesn't have that trigger feature
|
||||
* compiled in); anything else means triggers of that kind may exist but
|
||||
* couldn't be enumerated, so consumers must not treat the listing as a
|
||||
* complete snapshot (e.g. publishing should be blocked until a retry).
|
||||
*/
|
||||
failedKinds: WorkspaceTriggerKind[]
|
||||
}
|
||||
|
||||
/**
|
||||
* List every trigger of every kind in the workspace, normalized. EE-only kinds
|
||||
* are skipped without a license (their endpoints 404 on CE and would flood the
|
||||
* console). Failures are reported through `opts.onError` and recorded in
|
||||
* `failedKinds` rather than silently yielding an empty kind.
|
||||
*/
|
||||
export async function listAllWorkspaceTriggers(
|
||||
workspace: string,
|
||||
opts: { includeEeOnly: boolean; onError?: (message: string) => void }
|
||||
): Promise<WorkspaceTriggersListing> {
|
||||
const failedKinds: WorkspaceTriggerKind[] = []
|
||||
const perKind = await Promise.all(
|
||||
WORKSPACE_TRIGGER_KINDS.map(async (kind) => {
|
||||
const def = TRIGGER_KINDS[kind]
|
||||
if (def.eeOnly && !opts.includeEeOnly) return []
|
||||
let kindFailed = false
|
||||
const reportError = (message: string) => {
|
||||
kindFailed = true
|
||||
opts.onError?.(message)
|
||||
}
|
||||
let rows: Array<Record<string, any>>
|
||||
try {
|
||||
rows = await def.list(workspace, reportError)
|
||||
} catch (e: any) {
|
||||
if (e?.status !== 404) {
|
||||
reportError(`Failed to list ${kind} triggers: ${e?.message ?? e}`)
|
||||
}
|
||||
rows = []
|
||||
}
|
||||
if (kindFailed) failedKinds.push(kind)
|
||||
return rows.map(
|
||||
(r): WorkspaceTrigger => ({
|
||||
kind,
|
||||
path: r.path,
|
||||
script_path: r.script_path,
|
||||
is_flow: r.is_flow ?? false,
|
||||
summary: r.summary,
|
||||
config: r
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
return { triggers: perKind.flat(), failedKinds }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a trigger in its disabled state — the semantics differ per kind
|
||||
* (schedules use `enabled: false`, every other kind uses `mode: 'disabled'`)
|
||||
* and are encoded here once so importers can't get them wrong.
|
||||
*/
|
||||
export async function createWorkspaceTriggerDisabled(
|
||||
workspace: string,
|
||||
trigger: {
|
||||
kind: string
|
||||
path: string
|
||||
script_path: string
|
||||
is_flow: boolean
|
||||
summary?: string | null
|
||||
config?: Record<string, any> | null
|
||||
},
|
||||
opts: { hasEeLicense: boolean }
|
||||
): Promise<unknown> {
|
||||
const def = TRIGGER_KINDS[trigger.kind as WorkspaceTriggerKind]
|
||||
if (!def) throw new Error(`trigger kind '${trigger.kind}' not supported yet`)
|
||||
if (def.eeOnly && !opts.hasEeLicense) {
|
||||
throw new Error(`trigger kind '${trigger.kind}' requires Enterprise`)
|
||||
}
|
||||
if (def.provisionsOnCreate) {
|
||||
throw new Error(
|
||||
`${def.badge} triggers manage cloud subscriptions at creation — fill in the imported resource, then re-create this trigger manually`
|
||||
)
|
||||
}
|
||||
// Remote input: only the allowlisted portable slice may reach the create call.
|
||||
const config = portableTriggerConfig(trigger.kind, trigger.config)
|
||||
if (trigger.kind === 'schedule') {
|
||||
// Spread the portable config first so behavioral settings survive the
|
||||
// import (cron_version, retry, failure/recovery/success handlers,
|
||||
// no_flow_overlap, …) — restoring only cron+timezone would silently
|
||||
// change the schedule's semantics once re-enabled.
|
||||
return ScheduleService.createSchedule({
|
||||
workspace,
|
||||
requestBody: {
|
||||
...config,
|
||||
path: trigger.path,
|
||||
schedule: (config.schedule as string) ?? '0 0 * * * *',
|
||||
timezone: (config.timezone as string) ?? 'UTC',
|
||||
script_path: trigger.script_path,
|
||||
is_flow: trigger.is_flow,
|
||||
enabled: false,
|
||||
args: (config.args as any) ?? {},
|
||||
summary: trigger.summary ?? null
|
||||
}
|
||||
})
|
||||
}
|
||||
// `config` holds only allowlisted kind-specific fields; explicit fields win.
|
||||
return def.create!(workspace, {
|
||||
...config,
|
||||
path: trigger.path,
|
||||
script_path: trigger.script_path,
|
||||
is_flow: trigger.is_flow,
|
||||
summary: trigger.summary ?? null,
|
||||
mode: 'disabled'
|
||||
})
|
||||
}
|
||||
|
||||
/** The resource path a trigger's config points at, if its kind has one. */
|
||||
export function triggerResourcePath(t: WorkspaceTrigger): string | undefined {
|
||||
const field = TRIGGER_KINDS[t.kind]?.resourceField
|
||||
const v = field ? (t.config as any)?.[field] : undefined
|
||||
return typeof v === 'string' && v !== '' ? v : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Runnables a trigger's config references beyond its primary target, so they
|
||||
* can be bundled alongside it: `error_handler_path` (bare script path) for
|
||||
* non-schedule kinds, and schedules' `on_failure`/`on_recovery`/`on_success`
|
||||
* (`script/<path>` or `flow/<path>`) plus `dynamic_skip` (bare script path —
|
||||
* schedule creation refuses a dynamic_skip whose script doesn't exist, so an
|
||||
* unbundled one would make the import fail).
|
||||
*/
|
||||
export function triggerHandlerRefs(
|
||||
t: WorkspaceTrigger
|
||||
): Array<{ kind: 'script' | 'flow'; path: string }> {
|
||||
const c = t.config as any
|
||||
const out: Array<{ kind: 'script' | 'flow'; path: string }> = []
|
||||
if (t.kind === 'schedule') {
|
||||
for (const field of ['on_failure', 'on_recovery', 'on_success']) {
|
||||
const v = c?.[field]
|
||||
const m = typeof v === 'string' ? /^(script|flow)\/(.+)$/.exec(v) : null
|
||||
if (m) out.push({ kind: m[1] as 'script' | 'flow', path: m[2] })
|
||||
}
|
||||
if (typeof c?.dynamic_skip === 'string' && c.dynamic_skip !== '') {
|
||||
out.push({ kind: 'script', path: c.dynamic_skip })
|
||||
}
|
||||
} else {
|
||||
if (typeof c?.error_handler_path === 'string' && c.error_handler_path !== '') {
|
||||
out.push({ kind: 'script', path: c.error_handler_path })
|
||||
}
|
||||
if (t.kind === 'websocket') {
|
||||
// The URL itself can be a runnable ($script:<path> / $flow:<path>), and
|
||||
// initial messages can be runnable results.
|
||||
const um = typeof c?.url === 'string' ? /^\$(script|flow):(.+)$/.exec(c.url) : null
|
||||
if (um) out.push({ kind: um[1] as 'script' | 'flow', path: um[2] })
|
||||
for (const msg of Array.isArray(c?.initial_messages) ? c.initial_messages : []) {
|
||||
const rr = msg?.runnable_result
|
||||
if (rr && typeof rr.path === 'string' && rr.path !== '') {
|
||||
out.push({ kind: rr.is_flow ? 'flow' : 'script', path: rr.path })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Instance-side metadata that has no meaning outside the source workspace
|
||||
// (ownership, runtime state). Anything `last_*` or `captured_*` is also
|
||||
// dropped. Error handlers (error_handler_path/args, schedules' on_* fields)
|
||||
// are functional config and stay: their runnables are bundled with the
|
||||
// project and the paths relocated.
|
||||
// Portable behavior fields shared by every non-schedule kind (schedules carry
|
||||
// their own handler fields in configFields).
|
||||
const COMMON_CONFIG_FIELDS = ['error_handler_path', 'error_handler_args', 'retry']
|
||||
|
||||
/**
|
||||
* The portable slice of a trigger config — the ONLY fields that cross the
|
||||
* workspace boundary, in either direction. Applied on export (what reaches
|
||||
* the Hub) and on import (what a Hub export may feed into a create call), so
|
||||
* an upstream field addition can't leak out, and a crafted export can't
|
||||
* inject non-allowlisted fields like `permissioned_as`.
|
||||
*/
|
||||
export function portableTriggerConfig(
|
||||
kind: string,
|
||||
config: Record<string, unknown> | null | undefined
|
||||
): Record<string, unknown> {
|
||||
const def = TRIGGER_KINDS[kind as WorkspaceTriggerKind]
|
||||
if (!def || !config) return {}
|
||||
const fields =
|
||||
kind === 'schedule' ? def.configFields : [...def.configFields, ...COMMON_CONFIG_FIELDS]
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const f of fields) {
|
||||
if (config[f] !== undefined) out[f] = config[f]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Human-readable config facts per kind, for detail panes. */
|
||||
export function triggerDetails(t: WorkspaceTrigger): Array<{ label: string; value: string }> {
|
||||
const c = t.config as any
|
||||
const out: Array<{ label: string; value: string }> = []
|
||||
const push = (label: string, v: any) => {
|
||||
if (v != null && v !== '') out.push({ label, value: String(v) })
|
||||
}
|
||||
switch (t.kind) {
|
||||
case 'http':
|
||||
push('Route', `${(c.http_method ?? '').toUpperCase()} /${c.route_path ?? ''}`)
|
||||
push('Auth', c.authentication_method)
|
||||
break
|
||||
case 'schedule':
|
||||
push('Cron', c.schedule)
|
||||
push('Timezone', c.timezone)
|
||||
break
|
||||
case 'websocket':
|
||||
push('URL', c.url)
|
||||
break
|
||||
case 'kafka':
|
||||
push('Resource', c.kafka_resource_path)
|
||||
push('Group', c.group_id)
|
||||
push('Topics', (Array.isArray(c.topics) ? c.topics : []).join(', '))
|
||||
break
|
||||
case 'nats':
|
||||
push('Resource', c.nats_resource_path)
|
||||
push('Subjects', (Array.isArray(c.subjects) ? c.subjects : []).join(', '))
|
||||
push('Jetstream', c.use_jetstream)
|
||||
break
|
||||
case 'sqs':
|
||||
push('Queue', c.queue_url)
|
||||
push('Resource', c.aws_resource_path)
|
||||
break
|
||||
case 'mqtt':
|
||||
push('Resource', c.mqtt_resource_path)
|
||||
push(
|
||||
'Topics',
|
||||
(Array.isArray(c.subscribe_topics) ? c.subscribe_topics : [])
|
||||
.map((x: any) => x?.topic ?? x)
|
||||
.join(', ')
|
||||
)
|
||||
break
|
||||
case 'amqp':
|
||||
push('Resource', c.amqp_resource_path)
|
||||
push('Queue', c.queue_name)
|
||||
break
|
||||
case 'gcp':
|
||||
push('Resource', c.gcp_resource_path)
|
||||
push('Topic', c.topic_id)
|
||||
push('Subscription', c.subscription_id)
|
||||
break
|
||||
case 'azure':
|
||||
push('Resource', c.azure_resource_path)
|
||||
push('Scope', c.scope_resource_id)
|
||||
push('Subscription', c.subscription_name)
|
||||
break
|
||||
case 'postgres':
|
||||
push('Resource', c.postgres_resource_path)
|
||||
push('Publication', c.publication_name)
|
||||
break
|
||||
case 'email':
|
||||
push('Email prefix', c.local_part ? `${c.local_part}@…` : undefined)
|
||||
break
|
||||
}
|
||||
return out
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import SimpleEditor from '../SimpleEditor.svelte'
|
||||
|
||||
// Up/Down SQL editor for a data table migration. Two tabs keep the up (CREATE)
|
||||
// and down (DROP) SQL from cluttering the view; both are editable Monaco.
|
||||
let {
|
||||
up = $bindable(),
|
||||
down = $bindable(),
|
||||
// Bump to force the Monaco editors to re-mount with fresh code — Monaco does
|
||||
// not sync external `code` changes, so re-keying is how regenerated SQL shows.
|
||||
generation = 0
|
||||
}: { up: string; down: string; generation?: number } = $props()
|
||||
|
||||
let tab = $state<'up' | 'down'>('up')
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex gap-1 text-[11px]">
|
||||
{#each [{ id: 'up', label: 'Up' }, { id: 'down', label: 'Down' }] as t (t.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-2 py-0.5 font-medium {tab === t.id
|
||||
? 'bg-surface-selected text-primary'
|
||||
: 'text-secondary hover:bg-surface-hover'}"
|
||||
onclick={() => (tab = t.id as 'up' | 'down')}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#key generation}
|
||||
<div class="h-44 overflow-hidden rounded border bg-surface">
|
||||
{#if tab === 'up'}
|
||||
<SimpleEditor class="h-full" lang="sql" bind:code={up} small automaticLayout />
|
||||
{:else}
|
||||
<SimpleEditor class="h-full" lang="sql" bind:code={down} small automaticLayout />
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { canShareAsIframe, mergeShareState, type DeployItem } from './deployToHubSession.svelte'
|
||||
|
||||
function item(over: Partial<DeployItem> & Pick<DeployItem, 'key' | 'path' | 'kind'>): DeployItem {
|
||||
return { rec: 'none', ...over }
|
||||
}
|
||||
|
||||
describe('canShareAsIframe', () => {
|
||||
it('allows low-code apps and app-table raw apps', () => {
|
||||
expect(canShareAsIframe(item({ key: 'app:f/a', path: 'f/a', kind: 'app' }))).toBe(true)
|
||||
expect(
|
||||
canShareAsIframe(item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app', appTable: true }))
|
||||
).toBe(true)
|
||||
})
|
||||
it('hides the action for legacy raw apps (raw_app table only)', () => {
|
||||
// Legacy entries from RawAppService carry no appTable flag; AppService can't load them.
|
||||
expect(canShareAsIframe(item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app' }))).toBe(false)
|
||||
})
|
||||
it('never offers the action for flows or scripts', () => {
|
||||
expect(canShareAsIframe(item({ key: 'flow:f/f', path: 'f/f', kind: 'flow' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeShareState', () => {
|
||||
it('carries live public-share state from workspace items onto matching drafts', () => {
|
||||
const drafts = [item({ key: 'app:f/a', path: 'f/a', kind: 'app' })]
|
||||
const workspace = [
|
||||
item({
|
||||
key: 'app:f/a',
|
||||
path: 'f/a',
|
||||
kind: 'app',
|
||||
published: true,
|
||||
publicUrl: 'https://x/app'
|
||||
})
|
||||
]
|
||||
const merged = mergeShareState(drafts, workspace)
|
||||
expect(merged[0].published).toBe(true)
|
||||
expect(merged[0].publicUrl).toBe('https://x/app')
|
||||
})
|
||||
it('restores the app-table origin so app-table raw apps stay shareable', () => {
|
||||
const drafts = [item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app' })]
|
||||
const workspace = [item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app', appTable: true })]
|
||||
expect(canShareAsIframe(mergeShareState(drafts, workspace)[0])).toBe(true)
|
||||
})
|
||||
it('returns the same reference when nothing changes', () => {
|
||||
const drafts = [item({ key: 'flow:f/f', path: 'f/f', kind: 'flow' })]
|
||||
expect(mergeShareState(drafts, drafts)).toBe(drafts)
|
||||
})
|
||||
it('leaves drafts without a workspace match untouched', () => {
|
||||
const drafts = [item({ key: 'app:f/gone', path: 'f/gone', kind: 'app' })]
|
||||
const merged = mergeShareState(drafts, [item({ key: 'app:f/a', path: 'f/a', kind: 'app' })])
|
||||
expect(merged).toBe(drafts)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,869 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
classifyPath,
|
||||
extractScriptRefs,
|
||||
extractFlowRefs,
|
||||
extractAppRefs,
|
||||
buildPathMap,
|
||||
rewriteContent,
|
||||
rewriteTriggerConfig,
|
||||
rewriteFlowValue,
|
||||
rewriteAppValue,
|
||||
extractRawAppRefs,
|
||||
rewriteRawAppContent,
|
||||
buildProjectBundle,
|
||||
retargetProjectExport,
|
||||
collectExportVarPaths,
|
||||
extractTriggerConfigResourceRefs,
|
||||
extractVarRefsFromValue,
|
||||
type ProjectExport,
|
||||
type FetchedItem,
|
||||
type ItemRef
|
||||
} from './projectBundle'
|
||||
|
||||
describe('classifyPath', () => {
|
||||
it('internal for paths under the project folder', () => {
|
||||
expect(classifyPath('f/proj/db', 'proj')).toBe('internal')
|
||||
expect(classifyPath('f/proj', 'proj')).toBe('internal')
|
||||
})
|
||||
it('hub for hub paths', () => {
|
||||
expect(classifyPath('hub/16043/discord/send', 'proj')).toBe('hub')
|
||||
})
|
||||
it('external for user and other folders', () => {
|
||||
expect(classifyPath('u/admin/db', 'proj')).toBe('external')
|
||||
expect(classifyPath('f/other/db', 'proj')).toBe('external')
|
||||
})
|
||||
it('does not treat a prefix-only match as internal', () => {
|
||||
expect(classifyPath('f/project2/db', 'proj')).toBe('external')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractScriptRefs', () => {
|
||||
it('finds $res: and res:// resource refs, deduped', () => {
|
||||
const c = `const a = "$res:u/admin/db"; const b = "res://f/x/api"; const c2 = "$res:u/admin/db"`
|
||||
expect(extractScriptRefs(c)).toEqual([
|
||||
{ kind: 'resource', path: 'u/admin/db' },
|
||||
{ kind: 'resource', path: 'f/x/api' }
|
||||
])
|
||||
})
|
||||
it('returns nothing when no refs', () => {
|
||||
expect(extractScriptRefs('export async function main() {}')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractFlowRefs', () => {
|
||||
it('finds inline-code, static-input, and script-path refs', () => {
|
||||
const value = {
|
||||
modules: [
|
||||
{
|
||||
id: 'a',
|
||||
value: {
|
||||
type: 'rawscript',
|
||||
content: 'const db = "$res:u/admin/pg"',
|
||||
input_transforms: {
|
||||
other: { type: 'static', value: '$res:f/shared/api' },
|
||||
expr1: { type: 'javascript', expr: 'flow_input.x' }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
value: {
|
||||
type: 'branchone',
|
||||
branches: [
|
||||
{
|
||||
modules: [
|
||||
{ id: 'c', value: { type: 'script', path: 'u/admin/my_script' } },
|
||||
{ id: 'd', value: { type: 'script', path: 'hub/123/x/y' } }
|
||||
]
|
||||
}
|
||||
],
|
||||
default: [{ id: 'e', value: { type: 'rawscript', content: 'no refs' } }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
const refs = extractFlowRefs(value)
|
||||
expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/pg' })
|
||||
expect(refs).toContainEqual({ kind: 'resource', path: 'f/shared/api' })
|
||||
expect(refs).toContainEqual({ kind: 'script', path: 'u/admin/my_script' })
|
||||
expect(refs).toContainEqual({ kind: 'script', path: 'hub/123/x/y' })
|
||||
// a javascript expr (flow_input) is not a hardcoded ref
|
||||
expect(refs.filter((r) => r.path === 'flow_input.x')).toEqual([])
|
||||
})
|
||||
it('finds sub-flow refs from type: flow steps', () => {
|
||||
const value = {
|
||||
modules: [
|
||||
{ id: 'a', value: { type: 'flow', path: 'u/admin/sub_flow' } },
|
||||
{ id: 'b', value: { type: 'flow', path: 'hub/9/x/y' } }
|
||||
]
|
||||
}
|
||||
const refs = extractFlowRefs(value)
|
||||
expect(refs).toContainEqual({ kind: 'flow', path: 'u/admin/sub_flow' })
|
||||
expect(refs).toContainEqual({ kind: 'flow', path: 'hub/9/x/y' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPathMap', () => {
|
||||
it('reparents into the project folder keeping the leaf name', () => {
|
||||
const m = buildPathMap(['u/admin/db', 'f/other/api'], 'proj')
|
||||
expect(m.get('u/admin/db')).toBe('f/proj/db')
|
||||
expect(m.get('f/other/api')).toBe('f/proj/api')
|
||||
})
|
||||
it('suffixes collisions deterministically', () => {
|
||||
const m = buildPathMap(['u/alice/db', 'f/shared/db', 'u/bob/db'], 'proj')
|
||||
// sorted: f/shared/db, u/alice/db, u/bob/db
|
||||
expect(m.get('f/shared/db')).toBe('f/proj/db')
|
||||
expect(m.get('u/alice/db')).toBe('f/proj/db_2')
|
||||
expect(m.get('u/bob/db')).toBe('f/proj/db_3')
|
||||
})
|
||||
it('maps internal paths to themselves, preserving subfolder depth', () => {
|
||||
const m = buildPathMap(['f/proj/api', 'f/proj/sub/deep/script'], 'proj')
|
||||
expect(m.get('f/proj/api')).toBe('f/proj/api')
|
||||
expect(m.get('f/proj/sub/deep/script')).toBe('f/proj/sub/deep/script')
|
||||
})
|
||||
it('does not flatten two internal items sharing a leaf name', () => {
|
||||
const m = buildPathMap(['f/proj/a/x', 'f/proj/b/x'], 'proj')
|
||||
expect(m.get('f/proj/a/x')).toBe('f/proj/a/x')
|
||||
expect(m.get('f/proj/b/x')).toBe('f/proj/b/x')
|
||||
})
|
||||
it('relocates an external onto a suffix when its leaf collides with an internal path', () => {
|
||||
const m = buildPathMap(['f/proj/db', 'u/admin/db'], 'proj')
|
||||
expect(m.get('f/proj/db')).toBe('f/proj/db')
|
||||
expect(m.get('u/admin/db')).toBe('f/proj/db_2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rewriteContent', () => {
|
||||
it('rewrites mapped refs and leaves unmapped ones', () => {
|
||||
const map = new Map([['u/admin/db', 'f/proj/db']])
|
||||
expect(rewriteContent('x = "$res:u/admin/db"', map)).toBe('x = "$res:f/proj/db"')
|
||||
expect(rewriteContent('x = "res://u/admin/db"', map)).toBe('x = "$res:f/proj/db"')
|
||||
expect(rewriteContent('x = "$res:hub/1/a/b"', map)).toBe('x = "$res:hub/1/a/b"')
|
||||
})
|
||||
it('does not partial-match a longer path', () => {
|
||||
const map = new Map([['u/admin/db', 'f/proj/db']])
|
||||
// u/admin/db2 must not be rewritten by the u/admin/db entry
|
||||
expect(rewriteContent('x = "$res:u/admin/db2"', map)).toBe('x = "$res:u/admin/db2"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rewriteTriggerConfig', () => {
|
||||
const map = new Map([
|
||||
['f/proj/kafka', 'f/target/kafka'],
|
||||
['f/proj/script', 'f/target/script']
|
||||
])
|
||||
it('remaps plain resource path fields', () => {
|
||||
expect(
|
||||
rewriteTriggerConfig({ kafka_resource_path: 'f/proj/kafka', group_id: 'g1' }, map)
|
||||
).toEqual({ kafka_resource_path: 'f/target/kafka', group_id: 'g1' })
|
||||
})
|
||||
it('remaps nested objects, arrays, and $res: tokens', () => {
|
||||
expect(
|
||||
rewriteTriggerConfig(
|
||||
{
|
||||
nested: { path: 'f/proj/script' },
|
||||
list: ['f/proj/kafka', 'unrelated'],
|
||||
code: 'x = "$res:f/proj/kafka"'
|
||||
},
|
||||
map
|
||||
)
|
||||
).toEqual({
|
||||
nested: { path: 'f/target/script' },
|
||||
list: ['f/target/kafka', 'unrelated'],
|
||||
code: 'x = "$res:f/target/kafka"'
|
||||
})
|
||||
})
|
||||
it('leaves non-matching strings and non-string values untouched', () => {
|
||||
const config = { url: 'wss://example.com', port: 9092, enabled: true, extra: null }
|
||||
expect(rewriteTriggerConfig(config, map)).toEqual(config)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rewriteFlowValue', () => {
|
||||
it('rewrites inline code, static inputs, and script paths; clones input', () => {
|
||||
const map = new Map([
|
||||
['u/admin/pg', 'f/proj/pg'],
|
||||
['f/shared/api', 'f/proj/api'],
|
||||
['u/admin/my_script', 'f/proj/my_script']
|
||||
])
|
||||
const value = {
|
||||
modules: [
|
||||
{
|
||||
id: 'a',
|
||||
value: {
|
||||
type: 'rawscript',
|
||||
content: 'const db = "$res:u/admin/pg"',
|
||||
input_transforms: { other: { type: 'static', value: '$res:f/shared/api' } }
|
||||
}
|
||||
},
|
||||
{ id: 'b', value: { type: 'script', path: 'u/admin/my_script' } },
|
||||
{ id: 'c', value: { type: 'script', path: 'hub/1/keep/me' } }
|
||||
]
|
||||
}
|
||||
const out = rewriteFlowValue(value, map)
|
||||
expect(out.modules[0].value.content).toBe('const db = "$res:f/proj/pg"')
|
||||
expect(out.modules[0].value.input_transforms.other.value).toBe('$res:f/proj/api')
|
||||
expect(out.modules[1].value.path).toBe('f/proj/my_script')
|
||||
expect(out.modules[2].value.path).toBe('hub/1/keep/me')
|
||||
// original untouched (deep clone)
|
||||
expect(value.modules[0].value.content).toBe('const db = "$res:u/admin/pg"')
|
||||
})
|
||||
})
|
||||
|
||||
// A trimmed app value: a runnable-by-path component, a hub runnable, a $res in an
|
||||
// inline script, and incidental `f/...` text that must NOT be rewritten.
|
||||
const appValue = () => ({
|
||||
grid: [
|
||||
{
|
||||
data: {
|
||||
componentInput: {
|
||||
runnable: { type: 'runnableByPath', runType: 'script', path: 'u/admin/charts' }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
data: {
|
||||
componentInput: {
|
||||
runnable: { type: 'runnableByPath', runType: 'flow', path: 'f/shared/sync' }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
data: {
|
||||
componentInput: {
|
||||
runnable: { type: 'runnableByPath', runType: 'hubscript', path: 'hub/1/keep' }
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
hiddenInlineScripts: [
|
||||
{ name: 'h', inlineScript: { content: 'x = "$res:u/admin/pg"', language: 'deno' } }
|
||||
],
|
||||
someLabel: 'see docs at f/shared/sync for details'
|
||||
})
|
||||
|
||||
describe('extractAppRefs', () => {
|
||||
it('extracts runnable-by-path scripts/flows and $res resources, skips hub', () => {
|
||||
const refs = extractAppRefs(appValue())
|
||||
expect(refs).toContainEqual({ kind: 'script', path: 'u/admin/charts' })
|
||||
expect(refs).toContainEqual({ kind: 'flow', path: 'f/shared/sync' })
|
||||
expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/pg' })
|
||||
expect(refs.some((r) => r.path === 'hub/1/keep')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rewriteAppValue', () => {
|
||||
it('relocates runnable paths and $res, leaves hub refs and incidental text intact', () => {
|
||||
const map = new Map([
|
||||
['u/admin/charts', 'f/proj/charts'],
|
||||
['f/shared/sync', 'f/proj/sync'],
|
||||
['u/admin/pg', 'f/proj/pg']
|
||||
])
|
||||
const value = appValue()
|
||||
const out = rewriteAppValue(value, map)
|
||||
expect(out.grid[0].data.componentInput.runnable.path).toBe('f/proj/charts')
|
||||
expect(out.grid[1].data.componentInput.runnable.path).toBe('f/proj/sync')
|
||||
expect(out.grid[2].data.componentInput.runnable.path).toBe('hub/1/keep')
|
||||
expect(out.hiddenInlineScripts[0].inlineScript.content).toBe('x = "$res:f/proj/pg"')
|
||||
// incidental text untouched
|
||||
expect(out.someLabel).toBe('see docs at f/shared/sync for details')
|
||||
// original untouched (deep clone)
|
||||
expect(value.grid[0].data.componentInput.runnable.path).toBe('u/admin/charts')
|
||||
})
|
||||
})
|
||||
|
||||
describe('raw app (value.raw JSON string)', () => {
|
||||
const rawContent = () =>
|
||||
JSON.stringify({
|
||||
runnables: {
|
||||
a: { type: 'path', runType: 'flow', path: 'u/admin/sync' },
|
||||
b: { type: 'path', runType: 'script', path: 'f/shared/calc' },
|
||||
c: { type: 'path', runType: 'hubscript', path: 'hub/1/keep' }
|
||||
},
|
||||
files: { '/bundle.js': 'const conn = "$res:u/admin/pg"' }
|
||||
})
|
||||
|
||||
it('extractRawAppRefs sees nested runnables and $res, skips hub', () => {
|
||||
const refs = extractRawAppRefs(rawContent())
|
||||
expect(refs).toContainEqual({ kind: 'flow', path: 'u/admin/sync' })
|
||||
expect(refs).toContainEqual({ kind: 'script', path: 'f/shared/calc' })
|
||||
expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/pg' })
|
||||
expect(refs.some((r) => r.path === 'hub/1/keep')).toBe(false)
|
||||
})
|
||||
|
||||
it('rewriteRawAppContent relocates nested runnable paths and $res', () => {
|
||||
const map = new Map([
|
||||
['u/admin/sync', 'f/proj/sync'],
|
||||
['f/shared/calc', 'f/proj/calc'],
|
||||
['u/admin/pg', 'f/proj/pg']
|
||||
])
|
||||
const out = JSON.parse(rewriteRawAppContent(rawContent(), map))
|
||||
expect(out.runnables.a.path).toBe('f/proj/sync')
|
||||
expect(out.runnables.b.path).toBe('f/proj/calc')
|
||||
expect(out.runnables.c.path).toBe('hub/1/keep')
|
||||
expect(out.files['/bundle.js']).toBe('const conn = "$res:f/proj/pg"')
|
||||
})
|
||||
|
||||
it('falls back to $res scan on non-JSON content', () => {
|
||||
expect(extractRawAppRefs('x = "$res:u/admin/pg"')).toContainEqual({
|
||||
kind: 'resource',
|
||||
path: 'u/admin/pg'
|
||||
})
|
||||
expect(
|
||||
rewriteRawAppContent('x = "$res:u/admin/pg"', new Map([['u/admin/pg', 'f/proj/pg']]))
|
||||
).toBe('x = "$res:f/proj/pg"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildProjectBundle', () => {
|
||||
// A flow that calls an external script which itself hardcodes a resource.
|
||||
const flow: FetchedItem = {
|
||||
kind: 'flow',
|
||||
path: 'u/admin/my_flow',
|
||||
summary: 'Flow',
|
||||
value: {
|
||||
modules: [
|
||||
{ id: 'a', value: { type: 'script', path: 'u/admin/helper' } },
|
||||
{
|
||||
id: 'b',
|
||||
value: {
|
||||
type: 'rawscript',
|
||||
content: 'const x = "$res:f/shared/api"',
|
||||
input_transforms: {}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
const helper: FetchedItem = {
|
||||
kind: 'script',
|
||||
path: 'u/admin/helper',
|
||||
summary: 'Helper',
|
||||
language: 'bun',
|
||||
content: 'const db = "$res:u/admin/pg"; export async function main(){}'
|
||||
}
|
||||
|
||||
const deps = {
|
||||
fetchItem: async (ref: ItemRef) => {
|
||||
if (ref.path === 'u/admin/my_flow') return flow
|
||||
if (ref.path === 'u/admin/helper') return helper
|
||||
return undefined
|
||||
},
|
||||
resolveResourceType: async (path: string) => {
|
||||
if (path === 'u/admin/pg') return 'postgresql'
|
||||
if (path === 'f/shared/api') return 'http_api'
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
it('pulls in referenced scripts + resources and rewrites everything under the folder', async () => {
|
||||
const bundle = await buildProjectBundle(
|
||||
[{ kind: 'flow', path: 'u/admin/my_flow' }],
|
||||
'proj',
|
||||
deps
|
||||
)
|
||||
|
||||
// flow + transitively-pulled helper script are both bundled
|
||||
const byPath = Object.fromEntries(bundle.items.map((i) => [i.path, i]))
|
||||
expect(Object.keys(byPath).sort()).toEqual(['u/admin/helper', 'u/admin/my_flow'])
|
||||
|
||||
// items relocated under f/proj/
|
||||
expect(byPath['u/admin/my_flow'].newPath).toBe('f/proj/my_flow')
|
||||
expect(byPath['u/admin/helper'].newPath).toBe('f/proj/helper')
|
||||
|
||||
// flow's script-path ref rewritten to the helper's new path
|
||||
expect(byPath['u/admin/my_flow'].value.modules[0].value.path).toBe('f/proj/helper')
|
||||
// flow inline + helper code resource refs rewritten
|
||||
expect(byPath['u/admin/my_flow'].value.modules[1].value.content).toBe(
|
||||
'const x = "$res:f/proj/api"'
|
||||
)
|
||||
expect(byPath['u/admin/helper'].content).toContain('"$res:f/proj/pg"')
|
||||
|
||||
// resource stubs created at new paths with resolved types
|
||||
const stubs = Object.fromEntries(bundle.resourceStubs.map((s) => [s.originalPath, s]))
|
||||
expect(stubs['u/admin/pg'].newPath).toBe('f/proj/pg')
|
||||
expect(stubs['u/admin/pg'].resource_type).toBe('postgresql')
|
||||
expect(stubs['f/shared/api'].resource_type).toBe('http_api')
|
||||
|
||||
expect(bundle.unresolved).toEqual([])
|
||||
})
|
||||
|
||||
it('pulls in a sub-flow referenced by a type: flow step and rewrites its path', async () => {
|
||||
const parent: FetchedItem = {
|
||||
kind: 'flow',
|
||||
path: 'u/admin/parent_flow',
|
||||
value: { modules: [{ id: 'a', value: { type: 'flow', path: 'u/admin/sub_flow' } }] }
|
||||
}
|
||||
const sub: FetchedItem = {
|
||||
kind: 'flow',
|
||||
path: 'u/admin/sub_flow',
|
||||
value: {
|
||||
modules: [{ id: 'a', value: { type: 'script', path: 'hub/1/keep/me' } }]
|
||||
}
|
||||
}
|
||||
const d = {
|
||||
fetchItem: async (ref: ItemRef) => {
|
||||
if (ref.path === 'u/admin/parent_flow') return parent
|
||||
if (ref.path === 'u/admin/sub_flow') return sub
|
||||
return undefined
|
||||
},
|
||||
resolveResourceType: async () => undefined
|
||||
}
|
||||
const bundle = await buildProjectBundle(
|
||||
[{ kind: 'flow', path: 'u/admin/parent_flow' }],
|
||||
'proj',
|
||||
d
|
||||
)
|
||||
const byPath = Object.fromEntries(bundle.items.map((i) => [i.path, i]))
|
||||
// both flows bundled
|
||||
expect(Object.keys(byPath).sort()).toEqual(['u/admin/parent_flow', 'u/admin/sub_flow'])
|
||||
// parent's type: flow ref rewritten to the sub-flow's new path
|
||||
expect(byPath['u/admin/parent_flow'].value.modules[0].value.path).toBe('f/proj/sub_flow')
|
||||
expect(byPath['u/admin/sub_flow'].newPath).toBe('f/proj/sub_flow')
|
||||
// hub ref inside the sub-flow left untouched
|
||||
expect(byPath['u/admin/sub_flow'].value.modules[0].value.path).toBe('hub/1/keep/me')
|
||||
expect(bundle.unresolved).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves hub script references untouched and does not fetch them', async () => {
|
||||
const hubFlow: FetchedItem = {
|
||||
kind: 'flow',
|
||||
path: 'u/admin/hub_flow',
|
||||
value: { modules: [{ id: 'a', value: { type: 'script', path: 'hub/1/x/y' } }] }
|
||||
}
|
||||
const d = {
|
||||
fetchItem: async (ref: ItemRef) => (ref.path === 'u/admin/hub_flow' ? hubFlow : undefined),
|
||||
resolveResourceType: async () => undefined
|
||||
}
|
||||
const bundle = await buildProjectBundle([{ kind: 'flow', path: 'u/admin/hub_flow' }], 'proj', d)
|
||||
expect(bundle.items.map((i) => i.path)).toEqual(['u/admin/hub_flow'])
|
||||
expect(bundle.items[0].value.modules[0].value.path).toBe('hub/1/x/y')
|
||||
expect(bundle.unresolved).toEqual([])
|
||||
})
|
||||
|
||||
it('reports a missing item and an unresolvable resource as unresolved', async () => {
|
||||
const root: FetchedItem = {
|
||||
kind: 'flow',
|
||||
path: 'u/admin/root',
|
||||
value: {
|
||||
modules: [
|
||||
{ id: 'a', value: { type: 'script', path: 'u/admin/gone' } },
|
||||
{
|
||||
id: 'b',
|
||||
value: {
|
||||
type: 'rawscript',
|
||||
content: 'const x = "$res:u/admin/untyped"',
|
||||
input_transforms: {}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
const d = {
|
||||
fetchItem: async (ref: ItemRef) => (ref.path === 'u/admin/root' ? root : undefined),
|
||||
resolveResourceType: async () => undefined
|
||||
}
|
||||
const bundle = await buildProjectBundle([{ kind: 'flow', path: 'u/admin/root' }], 'proj', d)
|
||||
expect(bundle.unresolved.sort()).toEqual(['u/admin/gone', 'u/admin/untyped'])
|
||||
})
|
||||
|
||||
it('relocates $var:/$jsonvar: refs into the slug when it differs from the source folder', async () => {
|
||||
const flow: FetchedItem = {
|
||||
kind: 'flow',
|
||||
path: 'f/source_folder/main',
|
||||
value: {
|
||||
flow_env: { CFG: '$jsonvar:f/source_folder/cfg' },
|
||||
modules: [
|
||||
{
|
||||
id: 'a',
|
||||
value: {
|
||||
type: 'rawscript',
|
||||
// Whole-value ref is relocated; the inline literal is not.
|
||||
content: 'return "$var:f/source_folder/key"',
|
||||
input_transforms: { k: { type: 'static', value: '$var:f/source_folder/key' } }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
const d = {
|
||||
fetchItem: async (ref: ItemRef) => (ref.path === 'f/source_folder/main' ? flow : undefined),
|
||||
resolveResourceType: async () => undefined
|
||||
}
|
||||
const bundle = await buildProjectBundle(
|
||||
[{ kind: 'flow', path: 'f/source_folder/main' }],
|
||||
'kit',
|
||||
d
|
||||
)
|
||||
const v = bundle.items[0].value
|
||||
expect(v.modules[0].value.input_transforms.k.value).toBe('$var:f/kit/key')
|
||||
expect(v.flow_env.CFG).toBe('$jsonvar:f/kit/cfg')
|
||||
// Inline code literal is untouched.
|
||||
expect(v.modules[0].value.content).toBe('return "$var:f/source_folder/key"')
|
||||
})
|
||||
|
||||
it('dedupes a path missing as both a script and a flow', async () => {
|
||||
// A missing script + flow sharing a path each push the bare path once; the
|
||||
// list must stay unique so a keyed UI render of it can't collide.
|
||||
const root: FetchedItem = {
|
||||
kind: 'flow',
|
||||
path: 'u/admin/root',
|
||||
value: {
|
||||
modules: [
|
||||
{ id: 'a', value: { type: 'script', path: 'u/admin/dup' } },
|
||||
{ id: 'b', value: { type: 'flow', path: 'u/admin/dup' } }
|
||||
]
|
||||
}
|
||||
}
|
||||
const d = {
|
||||
fetchItem: async (ref: ItemRef) => (ref.path === 'u/admin/root' ? root : undefined),
|
||||
resolveResourceType: async () => undefined
|
||||
}
|
||||
const bundle = await buildProjectBundle([{ kind: 'flow', path: 'u/admin/root' }], 'proj', d)
|
||||
expect(bundle.unresolved).toEqual(['u/admin/dup'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractVarRefsFromValue', () => {
|
||||
it('collects whole-value `$var:`/`$jsonvar:` refs, deduped, walking nested JSON', () => {
|
||||
const value = {
|
||||
flow_env: { API: '$var:u/admin/key' },
|
||||
modules: [
|
||||
{ value: { input_transforms: { a: { type: 'static', value: '$var:f/proj/token' } } } },
|
||||
{ value: { input_transforms: { b: { type: 'static', value: '$jsonvar:u/admin/cfg' } } } },
|
||||
{ value: { input_transforms: { c: { type: 'static', value: '$var:u/admin/key' } } } }
|
||||
]
|
||||
}
|
||||
expect(extractVarRefsFromValue(value).sort()).toEqual([
|
||||
'f/proj/token',
|
||||
'u/admin/cfg',
|
||||
'u/admin/key'
|
||||
])
|
||||
})
|
||||
it('ignores a `$var:` token embedded in inline code (not a whole value)', () => {
|
||||
// The worker only substitutes a value that *is* the reference, so an inline
|
||||
// script literal must not be treated as a variable arg.
|
||||
const value = {
|
||||
modules: [{ value: { type: 'rawscript', content: 'return "$var:u/example/template"' } }]
|
||||
}
|
||||
expect(extractVarRefsFromValue(value)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('retargetProjectExport', () => {
|
||||
const baseExport = (): ProjectExport => ({
|
||||
project: { slug: 'proj', name: 'Proj', summary: '', readme: null },
|
||||
scripts: [
|
||||
{
|
||||
path: 'f/proj/hello',
|
||||
content: 'const r = "$res:f/proj/db"',
|
||||
summary: 'hello'
|
||||
}
|
||||
],
|
||||
flows: [
|
||||
{
|
||||
path: 'f/proj/main_flow',
|
||||
value: {
|
||||
modules: [
|
||||
{ id: 'a', value: { type: 'script', path: 'f/proj/hello', input_transforms: {} } }
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
apps: [
|
||||
{
|
||||
path: 'f/proj/dashboard',
|
||||
value: { grid: [{ data: { componentInput: { runnable: {} } } }] }
|
||||
},
|
||||
{
|
||||
path: 'f/proj/rawapp',
|
||||
app_type: 'raw',
|
||||
value: { raw: JSON.stringify({ files: {}, runnables: {} }) }
|
||||
}
|
||||
],
|
||||
resources: [{ path: 'f/proj/db', resource_type: 'postgresql' }],
|
||||
triggers: [
|
||||
{
|
||||
path: 'f/proj/every_day',
|
||||
kind: 'schedule',
|
||||
runnable_path: 'f/proj/hello',
|
||||
runnable_kind: 'script',
|
||||
config: { schedule: '0 0 12 * * *' }
|
||||
},
|
||||
{
|
||||
path: 'f/proj/kafka_in',
|
||||
kind: 'kafka',
|
||||
runnable_path: 'f/proj/hello',
|
||||
runnable_kind: 'script',
|
||||
config: { kafka_resource_path: 'f/proj/db' }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
it('returns the bundle unchanged when the folder matches the slug', () => {
|
||||
const bundle = baseExport()
|
||||
expect(retargetProjectExport(bundle, 'proj', 'proj')).toBe(bundle)
|
||||
})
|
||||
|
||||
it('relocates every item path and internal reference into the target folder', () => {
|
||||
const out = retargetProjectExport(baseExport(), 'proj', 'dest')
|
||||
expect(out.scripts[0].path).toBe('f/dest/hello')
|
||||
expect(out.scripts[0].content).toContain('$res:f/dest/db')
|
||||
expect(out.flows[0].path).toBe('f/dest/main_flow')
|
||||
expect(out.flows[0].value.modules[0].value.path).toBe('f/dest/hello')
|
||||
expect(out.apps.map((a) => a.path)).toEqual(['f/dest/dashboard', 'f/dest/rawapp'])
|
||||
expect(out.resources[0].path).toBe('f/dest/db')
|
||||
expect(out.triggers[0].path).toBe('f/dest/every_day')
|
||||
expect(out.triggers[0].runnable_path).toBe('f/dest/hello')
|
||||
// Plain-string resource path in a trigger config is remapped too.
|
||||
expect(out.triggers[1].config.kafka_resource_path).toBe('f/dest/db')
|
||||
})
|
||||
|
||||
it('leaves external and hub paths untouched', () => {
|
||||
const bundle = baseExport()
|
||||
bundle.scripts[0].content = 'const a = "$res:u/admin/db"; const b = "$res:hub/1/x"'
|
||||
const out = retargetProjectExport(bundle, 'proj', 'dest')
|
||||
expect(out.scripts[0].content).toContain('$res:u/admin/db')
|
||||
expect(out.scripts[0].content).toContain('$res:hub/1/x')
|
||||
})
|
||||
|
||||
it('retargets internal $var:/$jsonvar: refs but leaves external ones', () => {
|
||||
const bundle = baseExport()
|
||||
bundle.flows[0].value.modules[0].value.input_transforms = {
|
||||
key: { type: 'static', value: '$var:f/proj/api_key' },
|
||||
ext: { type: 'static', value: '$var:u/admin/personal' }
|
||||
}
|
||||
bundle.flows[0].value.flow_env = { CFG: '$jsonvar:f/proj/cfg' }
|
||||
bundle.triggers[1].config.queue_url = '$var:f/proj/sqs'
|
||||
const out = retargetProjectExport(bundle, 'proj', 'dest')
|
||||
const it = out.flows[0].value.modules[0].value.input_transforms
|
||||
expect(it.key.value).toBe('$var:f/dest/api_key')
|
||||
expect(it.ext.value).toBe('$var:u/admin/personal')
|
||||
expect(out.flows[0].value.flow_env.CFG).toBe('$jsonvar:f/dest/cfg')
|
||||
expect(out.triggers[1].config.queue_url).toBe('$var:f/dest/sqs')
|
||||
})
|
||||
|
||||
it('leaves an inert $var: literal embedded in inline code unchanged', () => {
|
||||
const bundle = baseExport()
|
||||
// Same path as a real runtime ref, but here it is a literal inside code: it
|
||||
// must not be rewritten even once the path enters the retarget map.
|
||||
bundle.flows[0].value.modules[0].value = {
|
||||
type: 'rawscript',
|
||||
content: 'return "$var:f/proj/api_key"',
|
||||
input_transforms: { real: { type: 'static', value: '$var:f/proj/api_key' } }
|
||||
}
|
||||
const out = retargetProjectExport(bundle, 'proj', 'dest')
|
||||
const mod = out.flows[0].value.modules[0].value
|
||||
expect(mod.content).toBe('return "$var:f/proj/api_key"')
|
||||
expect(mod.input_transforms.real.value).toBe('$var:f/dest/api_key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectExportVarPaths', () => {
|
||||
it('gathers variable refs from flows, apps, and triggers (deduped)', () => {
|
||||
const bundle: ProjectExport = {
|
||||
project: { slug: 'proj', name: 'P', summary: '', readme: null },
|
||||
scripts: [],
|
||||
flows: [{ path: 'f/proj/f', value: { flow_env: { A: '$var:f/proj/a' }, modules: [] } }],
|
||||
apps: [
|
||||
{
|
||||
path: 'f/proj/raw',
|
||||
app_type: 'raw',
|
||||
value: { raw: JSON.stringify({ runnables: { r: { fields: { x: '$var:u/admin/b' } } } }) }
|
||||
}
|
||||
],
|
||||
triggers: [{ path: 'f/proj/t', kind: 'sqs', config: { queue_url: '$jsonvar:f/proj/a' } }],
|
||||
resources: []
|
||||
}
|
||||
expect(collectExportVarPaths(bundle).sort()).toEqual(['f/proj/a', 'u/admin/b'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('trigger handler relocation', () => {
|
||||
it('rewriteTriggerConfig remaps script/- and flow/-prefixed handler refs', () => {
|
||||
const map = new Map([
|
||||
['u/admin/handler', 'f/proj/handler'],
|
||||
['u/admin/recovery_flow', 'f/proj/recovery_flow']
|
||||
])
|
||||
const out = rewriteTriggerConfig(
|
||||
{
|
||||
error_handler_path: 'u/admin/handler',
|
||||
on_failure: 'script/u/admin/handler',
|
||||
on_recovery: 'flow/u/admin/recovery_flow',
|
||||
on_success: 'script/u/admin/unmapped'
|
||||
},
|
||||
map
|
||||
)
|
||||
expect(out.error_handler_path).toBe('f/proj/handler')
|
||||
expect(out.on_failure).toBe('script/f/proj/handler')
|
||||
expect(out.on_recovery).toBe('flow/f/proj/recovery_flow')
|
||||
expect(out.on_success).toBe('script/u/admin/unmapped')
|
||||
})
|
||||
|
||||
it('remaps $script:/$flow: only in the url field, never in literal payloads', () => {
|
||||
const map = new Map([['u/admin/builder', 'f/proj/builder']])
|
||||
const out = rewriteTriggerConfig(
|
||||
{
|
||||
url: '$script:u/admin/builder',
|
||||
initial_messages: [{ raw_message: '$script:u/admin/builder' }]
|
||||
},
|
||||
map
|
||||
)
|
||||
expect(out.url).toBe('$script:f/proj/builder')
|
||||
expect(out.initial_messages[0].raw_message).toBe('$script:u/admin/builder')
|
||||
})
|
||||
|
||||
it('leaves literal handler-shaped strings in args untouched', () => {
|
||||
const map = new Map([['f/proj/handler', 'f/dest/handler']])
|
||||
const out = rewriteTriggerConfig(
|
||||
{
|
||||
on_failure: 'script/f/proj/handler',
|
||||
args: { note: 'script/f/proj/handler' }
|
||||
},
|
||||
map
|
||||
)
|
||||
expect(out.on_failure).toBe('script/f/dest/handler')
|
||||
expect(out.args.note).toBe('script/f/proj/handler')
|
||||
})
|
||||
|
||||
it('leaves nested url keys untouched, rewriting only the top-level websocket url', () => {
|
||||
const map = new Map([['u/admin/builder', 'f/proj/builder']])
|
||||
const out = rewriteTriggerConfig(
|
||||
{
|
||||
url: '$script:u/admin/builder',
|
||||
args: { url: '$script:u/admin/builder' }
|
||||
},
|
||||
map
|
||||
)
|
||||
expect(out.url).toBe('$script:f/proj/builder')
|
||||
expect(out.args.url).toBe('$script:u/admin/builder')
|
||||
})
|
||||
|
||||
it('extracts and relocates $res refs nested in static input transform JSON', () => {
|
||||
const value = {
|
||||
modules: [
|
||||
{
|
||||
id: 'a',
|
||||
value: {
|
||||
type: 'script',
|
||||
path: 'f/proj/step',
|
||||
input_transforms: {
|
||||
provider: { type: 'static', value: { resource: '$res:u/admin/openai' } },
|
||||
note: { type: 'static', value: 'plain text' }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
const refs = extractFlowRefs(value)
|
||||
expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/openai' })
|
||||
const out = rewriteFlowValue(value, new Map([['u/admin/openai', 'f/proj/openai']]))
|
||||
const it0 = out.modules[0].value.input_transforms
|
||||
expect(it0.provider.value).toEqual({ resource: '$res:f/proj/openai' })
|
||||
expect(typeof it0.note.value).toBe('string')
|
||||
})
|
||||
|
||||
it('retargetProjectExport remaps trigger error handlers with the bundle', () => {
|
||||
const bundle: ProjectExport = {
|
||||
project: { slug: 'proj', name: 'P', summary: '', readme: null },
|
||||
scripts: [{ path: 'f/proj/handler', content: '' }],
|
||||
flows: [],
|
||||
apps: [],
|
||||
resources: [],
|
||||
triggers: [
|
||||
{
|
||||
path: 'f/proj/sched',
|
||||
kind: 'schedule',
|
||||
runnable_path: 'f/proj/handler',
|
||||
runnable_kind: 'script',
|
||||
config: { schedule: '0 0 * * * *', on_failure: 'script/f/proj/handler' }
|
||||
},
|
||||
{
|
||||
path: 'f/proj/mq',
|
||||
kind: 'mqtt',
|
||||
runnable_path: 'f/proj/handler',
|
||||
runnable_kind: 'script',
|
||||
config: { error_handler_path: 'f/proj/handler' }
|
||||
}
|
||||
]
|
||||
}
|
||||
const out = retargetProjectExport(bundle, 'proj', 'dest')
|
||||
expect(out.triggers[0].config.on_failure).toBe('script/f/dest/handler')
|
||||
expect(out.triggers[1].config.error_handler_path).toBe('f/dest/handler')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractTriggerConfigResourceRefs', () => {
|
||||
it('collects $res: tokens nested anywhere in a trigger config', () => {
|
||||
expect(
|
||||
extractTriggerConfigResourceRefs({
|
||||
schedule: '0 0 * * * *',
|
||||
args: { channel: '$res:u/admin/slack' },
|
||||
on_failure_extra_args: { db: 'res://f/other/pg' },
|
||||
error_handler_args: { nested: { deep: '$res:u/admin/slack' } }
|
||||
})
|
||||
).toEqual(['u/admin/slack', 'f/other/pg'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('flow_env and preprocessor_module', () => {
|
||||
const flowValue = {
|
||||
modules: [],
|
||||
preprocessor_module: {
|
||||
id: 'pre',
|
||||
value: { type: 'script', path: 'u/admin/preproc', input_transforms: {} }
|
||||
},
|
||||
flow_env: { SLACK: '$res:u/admin/slack', PLAIN: 'not-a-ref' }
|
||||
}
|
||||
|
||||
it('walks nested children of the failure module', () => {
|
||||
const refs = extractFlowRefs({
|
||||
modules: [],
|
||||
failure_module: {
|
||||
id: 'failure',
|
||||
value: {
|
||||
type: 'forloopflow',
|
||||
modules: [
|
||||
{ id: 'f-a', value: { type: 'script', path: 'u/admin/cleanup', input_transforms: {} } }
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(refs).toContainEqual({ kind: 'script', path: 'u/admin/cleanup' })
|
||||
})
|
||||
|
||||
it('extractFlowRefs sees preprocessor scripts and flow_env resources', () => {
|
||||
const refs = extractFlowRefs(flowValue)
|
||||
expect(refs).toContainEqual({ kind: 'script', path: 'u/admin/preproc' })
|
||||
expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/slack' })
|
||||
})
|
||||
|
||||
it('sees and relocates $res refs nested inside JSON flow_env values', () => {
|
||||
const value = {
|
||||
modules: [],
|
||||
flow_env: { CFG: { db: '$res:u/admin/pg', opts: ['res://u/admin/s3'] } }
|
||||
}
|
||||
const refs = extractFlowRefs(value)
|
||||
expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/pg' })
|
||||
expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/s3' })
|
||||
const map = new Map([
|
||||
['u/admin/pg', 'f/proj/pg'],
|
||||
['u/admin/s3', 'f/proj/s3']
|
||||
])
|
||||
const out = rewriteFlowValue(value, map)
|
||||
expect(out.flow_env.CFG.db).toBe('$res:f/proj/pg')
|
||||
expect(out.flow_env.CFG.opts[0]).toBe('$res:f/proj/s3')
|
||||
})
|
||||
|
||||
it('rewriteFlowValue relocates both', () => {
|
||||
const map = new Map([
|
||||
['u/admin/preproc', 'f/proj/preproc'],
|
||||
['u/admin/slack', 'f/proj/slack']
|
||||
])
|
||||
const out = rewriteFlowValue(flowValue, map)
|
||||
expect(out.preprocessor_module.value.path).toBe('f/proj/preproc')
|
||||
expect(out.flow_env.SLACK).toBe('$res:f/proj/slack')
|
||||
expect(out.flow_env.PLAIN).toBe('not-a-ref')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,652 @@
|
||||
// Pure logic for the "project = folder" Hub bundle. A project is one folder
|
||||
// `f/<slug>/...`. Bundling: collect the transitive closure, relocate external
|
||||
// refs (`u/<user>/<name>`, `f/<other>/<name>` -> `f/<slug>/<name>`, `_2`/`_3`…
|
||||
// on collision) and rewrite them. Hub refs stay external; runtime string-concat
|
||||
// paths are out of scope. No API/Svelte deps so it's unit-testable.
|
||||
|
||||
import { getAllModules } from '$lib/components/flows/flowExplorer'
|
||||
import { isRunnableByPath } from '$lib/components/apps/inputType'
|
||||
|
||||
export type RefKind = 'resource' | 'script' | 'flow'
|
||||
|
||||
export interface Ref {
|
||||
kind: RefKind
|
||||
/** Bare path, without the `$res:` / `res://` prefix for resources. */
|
||||
path: string
|
||||
}
|
||||
|
||||
export type PathClass = 'internal' | 'hub' | 'external'
|
||||
|
||||
/** A single `$res:PATH` / `res://PATH` token (path captured in group 1). */
|
||||
const RES_TOKEN_RE = /(?:\$res:|res:\/\/)([\w\-./]+)/g
|
||||
|
||||
// A whole-string `$var:PATH` / `$jsonvar:PATH` value. The worker substitutes these
|
||||
// only when an argument value *is* the reference (walking nested JSON), never a
|
||||
// token embedded in inline code, so the whole value must match. `_KIND` captures
|
||||
// the prefix (group 1) and path (group 2) so a rewrite can preserve `var`/`jsonvar`.
|
||||
const VAR_VALUE_RE = /^\$(?:json)?var:([\w\-./]+)$/
|
||||
const VAR_VALUE_RE_KIND = /^\$(var|jsonvar):([\w\-./]+)$/
|
||||
|
||||
// Variable paths a value will resolve at runtime (flow static inputs, flow_env,
|
||||
// app runnable inputs, trigger config fields). Walk the parsed structure and match
|
||||
// whole string values so inline code carrying a literal `$var:` string is ignored.
|
||||
export function extractVarRefsFromValue(value: any): string[] {
|
||||
const out = new Set<string>()
|
||||
const walk = (v: any) => {
|
||||
if (typeof v === 'string') {
|
||||
const m = VAR_VALUE_RE.exec(v)
|
||||
if (m) out.add(m[1])
|
||||
} else if (Array.isArray(v)) {
|
||||
for (const x of v) walk(x)
|
||||
} else if (v && typeof v === 'object') {
|
||||
for (const k of Object.keys(v)) walk(v[k])
|
||||
}
|
||||
}
|
||||
walk(value)
|
||||
return [...out]
|
||||
}
|
||||
|
||||
export function classifyPath(path: string, slug: string): PathClass {
|
||||
if (path.startsWith(`f/${slug}/`) || path === `f/${slug}`) return 'internal'
|
||||
if (path.startsWith('hub/')) return 'hub'
|
||||
return 'external'
|
||||
}
|
||||
|
||||
export function extractScriptRefs(content: string): Ref[] {
|
||||
const out: Ref[] = []
|
||||
const seen = new Set<string>()
|
||||
let m: RegExpExecArray | null
|
||||
RES_TOKEN_RE.lastIndex = 0
|
||||
while ((m = RES_TOKEN_RE.exec(content)) !== null) {
|
||||
if (!seen.has(m[1])) {
|
||||
seen.add(m[1])
|
||||
out.push({ kind: 'resource', path: m[1] })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* References inside a flow value:
|
||||
* - inline rawscript code with `$res:` (resource)
|
||||
* - static step inputs whose value is a `$res:` literal (resource)
|
||||
* - `type: script` steps that reference a script by path (script)
|
||||
* - `type: flow` steps that reference a sub-flow by path (flow)
|
||||
*/
|
||||
export function extractFlowRefs(value: any): Ref[] {
|
||||
const out: Ref[] = []
|
||||
const seen = new Set<string>()
|
||||
const add = (kind: RefKind, path: string) => {
|
||||
const key = `${kind}:${path}`
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
out.push({ kind, path })
|
||||
}
|
||||
}
|
||||
// getAllModules flattens the whole tree (loops, branches, aiagent tools,
|
||||
// failure module) so each module only needs local inspection; the
|
||||
// preprocessor module sits outside `modules` and is walked the same way.
|
||||
for (const mod of allFlowModules(value)) {
|
||||
const v: any = (mod as any)?.value
|
||||
if (!v || typeof v !== 'object') continue
|
||||
if (v.type === 'script' && typeof v.path === 'string') add('script', v.path)
|
||||
if (v.type === 'flow' && typeof v.path === 'string') add('flow', v.path)
|
||||
if (typeof v.content === 'string') {
|
||||
for (const r of extractScriptRefs(v.content)) add('resource', r.path)
|
||||
}
|
||||
const it = v.input_transforms
|
||||
if (it && typeof it === 'object') {
|
||||
for (const key of Object.keys(it)) {
|
||||
const t = it[key]
|
||||
// Static values can be a bare `$res:` string or arbitrary JSON with
|
||||
// refs nested anywhere — the worker resolves both, so scan the full
|
||||
// serialization.
|
||||
if (t?.type === 'static' && t.value !== undefined) {
|
||||
const text = typeof t.value === 'string' ? t.value : JSON.stringify(t.value)
|
||||
for (const r of extractScriptRefs(text)) add('resource', r.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// flow_env values support `$res:path` references — as whole string values or
|
||||
// nested inside JSON values (the worker resolves both), so scan the full
|
||||
// serialization.
|
||||
if (value?.flow_env && typeof value.flow_env === 'object') {
|
||||
for (const r of extractScriptRefs(JSON.stringify(value.flow_env))) add('resource', r.path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Every module of a flow value: the tree under `modules`, the failure module,
|
||||
// and the preprocessor module (which lives outside `modules`). Any walk over a
|
||||
// flow's modules must go through this — a walk that misses a module class
|
||||
// silently drops its dependencies from bundles or migrations. All three go in
|
||||
// the root list (not getAllModules' failure_module parameter, which appends
|
||||
// the module without expanding its descendants) so nested children of a
|
||||
// failure or preprocessor module are walked too.
|
||||
export function allFlowModules(value: any) {
|
||||
return getAllModules([
|
||||
...(value?.modules ?? []),
|
||||
...(value?.preprocessor_module ? [value.preprocessor_module] : []),
|
||||
...(value?.failure_module ? [value.failure_module] : [])
|
||||
])
|
||||
}
|
||||
|
||||
// Visit every object node in an app value tree (JSON-safe, no cycles).
|
||||
function walkAppNodes(value: any, visit: (node: Record<string, any>) => void): void {
|
||||
if (value == null || typeof value !== 'object') return
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) walkAppNodes(v, visit)
|
||||
return
|
||||
}
|
||||
visit(value)
|
||||
for (const k of Object.keys(value)) walkAppNodes(value[k], visit)
|
||||
}
|
||||
|
||||
// `runnableByPath`/`path` nodes reference a workspace runnable by path.
|
||||
function runnableRef(node: Record<string, any>): Ref | undefined {
|
||||
if (!isRunnableByPath(node as any) || typeof node.path !== 'string') return undefined
|
||||
if (node.runType === 'flow') return { kind: 'flow', path: node.path }
|
||||
if (node.runType === 'script') return { kind: 'script', path: node.path }
|
||||
return undefined // hubscript -> external hub, ignored
|
||||
}
|
||||
|
||||
// App refs: `$res:` resources anywhere in the value, plus script/flow runnables
|
||||
// referenced by path in components.
|
||||
export function extractAppRefs(value: any): Ref[] {
|
||||
const out: Ref[] = []
|
||||
const seen = new Set<string>()
|
||||
const add = (kind: RefKind, path: string) => {
|
||||
const key = `${kind}:${path}`
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
out.push({ kind, path })
|
||||
}
|
||||
}
|
||||
walkAppNodes(value, (node) => {
|
||||
const r = runnableRef(node)
|
||||
if (r) add(r.kind, r.path)
|
||||
})
|
||||
for (const r of extractScriptRefs(JSON.stringify(value ?? {}))) add('resource', r.path)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the relocation map. Internal paths (`f/<slug>/...`) map to themselves
|
||||
* and are reserved first; external paths relocate to `f/<slug>/<name>` (`_2`/`_3`…
|
||||
* on collision). Input is sorted so suffix assignment is deterministic.
|
||||
*/
|
||||
export function buildPathMap(paths: Iterable<string>, slug: string): Map<string, string> {
|
||||
const map = new Map<string, string>()
|
||||
const used = new Set<string>()
|
||||
const sorted = [...new Set(paths)].sort()
|
||||
for (const p of sorted) {
|
||||
if (classifyPath(p, slug) === 'internal') {
|
||||
map.set(p, p)
|
||||
used.add(p)
|
||||
}
|
||||
}
|
||||
for (const old of sorted) {
|
||||
if (map.has(old)) continue
|
||||
const name = old.split('/').filter(Boolean).pop() ?? old
|
||||
let candidate = `f/${slug}/${name}`
|
||||
let n = 2
|
||||
while (used.has(candidate)) candidate = `f/${slug}/${name}_${n++}`
|
||||
used.add(candidate)
|
||||
map.set(old, candidate)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// Both ref forms normalize to `$res:` on rewrite.
|
||||
export function rewriteContent(content: string, map: Map<string, string>): string {
|
||||
return content.replace(RES_TOKEN_RE, (whole, path) => {
|
||||
const next = map.get(path)
|
||||
return next ? `$res:${next}` : whole
|
||||
})
|
||||
}
|
||||
|
||||
// Structurally relocate whole-string `$var:`/`$jsonvar:` values — the only form the
|
||||
// worker resolves. Walks the parsed value so an inert token embedded in inline code
|
||||
// or arbitrary text is left untouched, unlike token replacement over serialized
|
||||
// strings. Only paths present in the map move (the retarget map carries variables).
|
||||
export function rewriteVarRefsInValue(value: any, map: Map<string, string>): any {
|
||||
if (typeof value === 'string') {
|
||||
const m = VAR_VALUE_RE_KIND.exec(value)
|
||||
if (m) {
|
||||
const next = map.get(m[2])
|
||||
if (next) return `$${m[1]}:${next}`
|
||||
}
|
||||
return value
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((v) => rewriteVarRefsInValue(v, map))
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, any> = {}
|
||||
for (const k of Object.keys(value)) out[k] = rewriteVarRefsInValue(value[k], map)
|
||||
return out
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* `$res:`/`res://` tokens anywhere in a trigger config — schedule args,
|
||||
* on_*_extra_args, error_handler_args, … (e.g. the built-in Slack handler
|
||||
* stores its channel resource this way). These must enter the bundle path map
|
||||
* so `rewriteTriggerConfig` relocates them and a stub is exported.
|
||||
*/
|
||||
export function extractTriggerConfigResourceRefs(config: any): string[] {
|
||||
return extractScriptRefs(JSON.stringify(config ?? {})).map((r) => r.path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger configs reference resources as plain path strings (e.g.
|
||||
* `kafka_resource_path: "f/slug/db"`), not `$res:` tokens, so token rewriting
|
||||
* misses them. Deep-walk the config and remap any string that exact-matches a
|
||||
* map key (map keys are full bundle paths, so an exact match is a reference),
|
||||
* or a `script/<path>`/`flow/<path>` handler reference (schedules' on_failure
|
||||
* et al.), falling back to `$res:` token rewriting for embedded refs.
|
||||
*/
|
||||
// Top-level config fields whose string values are prefixed runnable refs.
|
||||
// Prefixed forms are remapped ONLY in these known positions: deciding meaning
|
||||
// from string shape alone rewrote literal payloads that merely looked like
|
||||
// refs. Bare-path exact matches and $res: tokens stay position-independent.
|
||||
const HANDLER_REF_FIELDS = new Set(['on_failure', 'on_recovery', 'on_success'])
|
||||
|
||||
export function rewriteTriggerConfig(config: any, map: Map<string, string>, depth = 0): any {
|
||||
if (typeof config === 'string') {
|
||||
const direct = map.get(config)
|
||||
if (direct) return direct
|
||||
return rewriteContent(config, map)
|
||||
}
|
||||
if (Array.isArray(config)) return config.map((v) => rewriteTriggerConfig(v, map, depth + 1))
|
||||
if (config && typeof config === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(config).map(([k, v]) => {
|
||||
if (depth === 0 && typeof v === 'string') {
|
||||
// Websocket url: $script:<path> / $flow:<path>.
|
||||
if (k === 'url') {
|
||||
const m = /^\$(script|flow):(.+)$/.exec(v)
|
||||
if (m && map.has(m[2])) return [k, `$${m[1]}:${map.get(m[2])}`]
|
||||
}
|
||||
// Schedule handlers: script/<path> / flow/<path>.
|
||||
if (HANDLER_REF_FIELDS.has(k)) {
|
||||
const m = /^(script|flow)\/(.+)$/.exec(v)
|
||||
if (m && map.has(m[2])) return [k, `${m[1]}/${map.get(m[2])}`]
|
||||
}
|
||||
}
|
||||
return [k, rewriteTriggerConfig(v, map, depth + 1)]
|
||||
})
|
||||
)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
export function rewriteFlowValue(value: any, map: Map<string, string>): any {
|
||||
const cloned = JSON.parse(JSON.stringify(value ?? {}))
|
||||
for (const mod of allFlowModules(cloned)) {
|
||||
const v: any = (mod as any)?.value
|
||||
if (!v || typeof v !== 'object') continue
|
||||
if (
|
||||
(v.type === 'script' || v.type === 'flow') &&
|
||||
typeof v.path === 'string' &&
|
||||
map.has(v.path)
|
||||
) {
|
||||
v.path = map.get(v.path)
|
||||
}
|
||||
if (typeof v.content === 'string') v.content = rewriteContent(v.content, map)
|
||||
const it = v.input_transforms
|
||||
if (it && typeof it === 'object') {
|
||||
for (const key of Object.keys(it)) {
|
||||
const t = it[key]
|
||||
// Mirror extraction: rewrite refs wherever they sit, preserving the
|
||||
// value's type (a string stays a string, JSON round-trips).
|
||||
if (t?.type === 'static' && t.value !== undefined) {
|
||||
if (typeof t.value === 'string') {
|
||||
t.value = rewriteContent(t.value, map)
|
||||
} else {
|
||||
t.value = JSON.parse(rewriteContent(JSON.stringify(t.value), map))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cloned?.flow_env && typeof cloned.flow_env === 'object') {
|
||||
// Tokens can sit inside nested JSON values, not just string values; the
|
||||
// serialize→rewrite→parse round-trip reaches all of them (paths contain
|
||||
// no characters that would break JSON string literals).
|
||||
cloned.flow_env = JSON.parse(rewriteContent(JSON.stringify(cloned.flow_env), map))
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
// Relocate `$res:` tokens (one round-trip, also produces a fresh clone) then
|
||||
// runnable-by-path refs structurally. Incidental `f/<slug>/` strings stay intact.
|
||||
export function rewriteAppValue(value: any, map: Map<string, string>): any {
|
||||
if (value == null) return value
|
||||
const cloned = JSON.parse(rewriteContent(JSON.stringify(value), map))
|
||||
walkAppNodes(cloned, (node) => {
|
||||
if (runnableRef(node) && map.has(node.path)) node.path = map.get(node.path)
|
||||
})
|
||||
return cloned
|
||||
}
|
||||
|
||||
// Raw/compiled apps store their structure as a JSON string (`{ runnables, files }`).
|
||||
// Parse it so runnable-by-path refs in the runnables map are seen, reusing the
|
||||
// same walk; fall back to plain `$res:` scanning if it isn't valid JSON.
|
||||
export function extractRawAppRefs(content: string): Ref[] {
|
||||
let parsed: any
|
||||
try {
|
||||
parsed = JSON.parse(content)
|
||||
} catch {
|
||||
return extractScriptRefs(content)
|
||||
}
|
||||
return extractAppRefs(parsed)
|
||||
}
|
||||
|
||||
export function rewriteRawAppContent(content: string, map: Map<string, string>): string {
|
||||
let parsed: any
|
||||
try {
|
||||
parsed = JSON.parse(content)
|
||||
} catch {
|
||||
return rewriteContent(content, map)
|
||||
}
|
||||
return JSON.stringify(rewriteAppValue(parsed, map))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hub project export format (what /projects/{slug}/export returns) and its
|
||||
// retargeting into a destination folder. Kept here, next to the rewriters,
|
||||
// so the bundle format is defined in one module for both publish and install.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ExportItem = Record<string, any>
|
||||
export interface ProjectMigration {
|
||||
datatable_name: string
|
||||
sql: string
|
||||
sql_down?: string
|
||||
enabled: boolean
|
||||
}
|
||||
export interface ProjectExport {
|
||||
project: { slug: string; name: string; summary: string; readme: string | null }
|
||||
scripts: ExportItem[]
|
||||
flows: ExportItem[]
|
||||
apps: ExportItem[]
|
||||
resources: ExportItem[]
|
||||
triggers: ExportItem[]
|
||||
migrations?: ProjectMigration[]
|
||||
}
|
||||
|
||||
// Map bundled paths `f/<fromSlug>/...` -> `f/<folder>/...`. Only enumerated
|
||||
// paths go in, so rewriters touch real refs, never incidental text.
|
||||
export function buildRetargetMap(
|
||||
bundle: ProjectExport,
|
||||
fromSlug: string,
|
||||
folder: string
|
||||
): Map<string, string> {
|
||||
const map = new Map<string, string>()
|
||||
const prefix = `f/${fromSlug}/`
|
||||
const add = (p: unknown) => {
|
||||
if (typeof p === 'string' && p.startsWith(prefix)) {
|
||||
map.set(p, `f/${folder}/${p.slice(prefix.length)}`)
|
||||
}
|
||||
}
|
||||
for (const s of bundle.scripts) add(s.path)
|
||||
for (const f of bundle.flows) add(f.path)
|
||||
for (const a of bundle.apps) add(a.path)
|
||||
for (const r of bundle.resources) add(r.path)
|
||||
for (const t of bundle.triggers) {
|
||||
add(t.path)
|
||||
add(t.runnable_path)
|
||||
}
|
||||
// Variables aren't enumerated in the export; their `$var:`/`$jsonvar:` refs live
|
||||
// inside item values. Relocate the internal ones so a renamed-folder import
|
||||
// rewrites them into the target folder instead of retaining the old prefix.
|
||||
for (const p of collectExportVarPaths(bundle)) add(p)
|
||||
return map
|
||||
}
|
||||
|
||||
// Internal-or-external variable paths referenced by the export's flows, apps and
|
||||
// triggers. Scripts carry no variable args. Raw apps hold their structure in the
|
||||
// `value.raw` JSON string.
|
||||
export function collectExportVarPaths(bundle: ProjectExport): string[] {
|
||||
const out = new Set<string>()
|
||||
const collect = (value: any) => {
|
||||
for (const p of extractVarRefsFromValue(value)) out.add(p)
|
||||
}
|
||||
for (const f of bundle.flows) collect(f.value)
|
||||
for (const a of bundle.apps) collect(a.app_type === 'raw' ? safeParseRaw(a.value?.raw) : a.value)
|
||||
for (const t of bundle.triggers) collect(t.config)
|
||||
return [...out]
|
||||
}
|
||||
|
||||
function safeParseRaw(raw: unknown): any {
|
||||
if (typeof raw !== 'string') return undefined
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Structural retarget: rewrite each item's path and its internal refs,
|
||||
// leaving Hub refs and arbitrary content untouched.
|
||||
export function retargetProjectExport(
|
||||
bundle: ProjectExport,
|
||||
fromSlug: string,
|
||||
folder: string
|
||||
): ProjectExport {
|
||||
if (folder === fromSlug) return bundle
|
||||
const map = buildRetargetMap(bundle, fromSlug, folder)
|
||||
const remap = (p: unknown) => (typeof p === 'string' ? (map.get(p) ?? p) : p)
|
||||
return {
|
||||
...bundle,
|
||||
scripts: bundle.scripts.map((s) => ({
|
||||
...s,
|
||||
path: remap(s.path),
|
||||
content: rewriteContent(s.content ?? '', map)
|
||||
})),
|
||||
flows: bundle.flows.map((f) => ({
|
||||
...f,
|
||||
path: remap(f.path),
|
||||
value: rewriteVarRefsInValue(rewriteFlowValue(f.value, map), map)
|
||||
})),
|
||||
apps: bundle.apps.map((a) => ({
|
||||
...a,
|
||||
path: remap(a.path),
|
||||
// Raw apps keep their structure in the `value.raw` JSON string.
|
||||
value:
|
||||
a.app_type === 'raw'
|
||||
? {
|
||||
...a.value,
|
||||
raw: rewriteRawVarRefs(rewriteRawAppContent(a.value?.raw ?? '', map), map)
|
||||
}
|
||||
: rewriteVarRefsInValue(rewriteAppValue(a.value, map), map)
|
||||
})),
|
||||
resources: bundle.resources.map((r) => ({ ...r, path: remap(r.path) })),
|
||||
triggers: bundle.triggers.map((t) => ({
|
||||
...t,
|
||||
path: remap(t.path),
|
||||
runnable_path: remap(t.runnable_path),
|
||||
// Configs hold `$res:` tokens, plain resource paths (kafka_resource_path
|
||||
// etc.) and whole-string `$var:` values — rewrite all three.
|
||||
config: t.config ? rewriteVarRefsInValue(rewriteTriggerConfig(t.config, map), map) : t.config
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Var relocation for a raw app's `value.raw` JSON string: parse, structurally
|
||||
// rewrite whole-string var values, re-serialize; leave invalid JSON untouched.
|
||||
function rewriteRawVarRefs(raw: string, map: Map<string, string>): string {
|
||||
const parsed = safeParseRaw(raw)
|
||||
if (parsed === undefined) return raw
|
||||
return JSON.stringify(rewriteVarRefsInValue(parsed, map))
|
||||
}
|
||||
|
||||
export type ItemKind = 'script' | 'flow' | 'app' | 'raw_app'
|
||||
|
||||
export interface ItemRef {
|
||||
kind: ItemKind
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface FetchedItem {
|
||||
kind: ItemKind
|
||||
path: string
|
||||
summary?: string
|
||||
description?: string
|
||||
/** scripts + raw_apps */
|
||||
content?: string
|
||||
/** flows + apps */
|
||||
value?: any
|
||||
/** scripts */
|
||||
language?: string
|
||||
schema?: any
|
||||
lock?: string
|
||||
scriptKind?: string
|
||||
}
|
||||
|
||||
export interface BundleDeps {
|
||||
/** Fetch a workspace item by ref, or undefined if it doesn't exist. */
|
||||
fetchItem: (ref: ItemRef) => Promise<FetchedItem | undefined>
|
||||
/** Resolve a resource path to its type, or undefined if missing. */
|
||||
resolveResourceType: (path: string) => Promise<string | undefined>
|
||||
}
|
||||
|
||||
export interface BundledItem extends FetchedItem {
|
||||
/** Path the item takes inside the project folder. */
|
||||
newPath: string
|
||||
}
|
||||
|
||||
export interface ResourceStub {
|
||||
originalPath: string
|
||||
newPath: string
|
||||
resource_type: string
|
||||
}
|
||||
|
||||
export interface ProjectBundle {
|
||||
items: BundledItem[]
|
||||
resourceStubs: ResourceStub[]
|
||||
/** Original -> relocated path for every item and resource (incl. unresolved). */
|
||||
pathMap: Map<string, string>
|
||||
/** External paths we couldn't fetch/resolve (missing items or untyped resources). */
|
||||
unresolved: string[]
|
||||
}
|
||||
|
||||
function refsForFetched(item: FetchedItem): Ref[] {
|
||||
if (item.kind === 'script') return extractScriptRefs(item.content ?? '')
|
||||
if (item.kind === 'flow') return extractFlowRefs(item.value)
|
||||
if (item.kind === 'app') return extractAppRefs(item.value)
|
||||
if (item.kind === 'raw_app') return extractRawAppRefs(item.content ?? '')
|
||||
return []
|
||||
}
|
||||
|
||||
// Whole-string `$var:`/`$jsonvar:` paths an item resolves at runtime. Scripts carry
|
||||
// no variable args; raw apps hold their structure in the `content` JSON string.
|
||||
function varRefsForFetched(item: FetchedItem): string[] {
|
||||
if (item.kind === 'flow' || item.kind === 'app') return extractVarRefsFromValue(item.value)
|
||||
if (item.kind === 'raw_app') return extractVarRefsFromValue(safeParseRaw(item.content))
|
||||
return []
|
||||
}
|
||||
|
||||
// Walks the transitive closure: scripts referenced by path are pulled in
|
||||
// recursively, resources become empty stubs, hub refs stay external.
|
||||
export async function buildProjectBundle(
|
||||
seed: ItemRef[],
|
||||
slug: string,
|
||||
deps: BundleDeps,
|
||||
extraResourcePaths: string[] = [],
|
||||
extraVarPaths: string[] = []
|
||||
): Promise<ProjectBundle> {
|
||||
const fetched = new Map<string, FetchedItem>()
|
||||
const queued = new Set<string>()
|
||||
const resourcePaths = new Set<string>()
|
||||
const varPaths = new Set<string>()
|
||||
const unresolved: string[] = []
|
||||
|
||||
// Resources and variables referenced by triggers (by config value, not `$res:`
|
||||
// in code) — relocated through the same map so the export stays slug-relative.
|
||||
for (const p of extraResourcePaths) {
|
||||
if (classifyPath(p, slug) !== 'hub') resourcePaths.add(p)
|
||||
}
|
||||
for (const p of extraVarPaths) varPaths.add(p)
|
||||
|
||||
// Key by `${kind}:${path}`, not bare path: a script and flow can share a path,
|
||||
// and keying by path alone would silently drop one.
|
||||
const refKey = (kind: string, path: string) => `${kind}:${path}`
|
||||
|
||||
// Refs at the same BFS depth are independent: fetch each level concurrently.
|
||||
let level: ItemRef[] = []
|
||||
for (const s of seed) {
|
||||
const key = refKey(s.kind, s.path)
|
||||
if (!queued.has(key)) {
|
||||
queued.add(key)
|
||||
level.push(s)
|
||||
}
|
||||
}
|
||||
while (level.length > 0) {
|
||||
const results = await Promise.all(
|
||||
level.map(async (ref) => ({ ref, item: await deps.fetchItem(ref) }))
|
||||
)
|
||||
const next: ItemRef[] = []
|
||||
for (const { ref, item } of results) {
|
||||
if (!item) {
|
||||
unresolved.push(ref.path)
|
||||
continue
|
||||
}
|
||||
fetched.set(refKey(ref.kind, ref.path), item)
|
||||
for (const r of refsForFetched(item)) {
|
||||
if (classifyPath(r.path, slug) === 'hub') continue
|
||||
if (r.kind === 'resource') {
|
||||
resourcePaths.add(r.path)
|
||||
} else if (r.kind === 'script' || r.kind === 'flow') {
|
||||
const key = refKey(r.kind, r.path)
|
||||
if (!queued.has(key)) {
|
||||
queued.add(key)
|
||||
next.push({ kind: r.kind, path: r.path })
|
||||
}
|
||||
}
|
||||
}
|
||||
// Relocate the item's runtime variable refs into the project folder too, so
|
||||
// the export is slug-relative regardless of the source folder (import then
|
||||
// materializes them as placeholders). Variables are never hub-hosted.
|
||||
for (const p of varRefsForFetched(item)) varPaths.add(p)
|
||||
}
|
||||
level = next
|
||||
}
|
||||
|
||||
const fetchedItems = [...fetched.values()]
|
||||
const itemPaths = fetchedItems.map((it) => it.path)
|
||||
const map = buildPathMap([...itemPaths, ...resourcePaths, ...varPaths], slug)
|
||||
|
||||
const items: BundledItem[] = fetchedItems.map((it) => {
|
||||
const rewritten: BundledItem = { ...it, newPath: map.get(it.path) ?? it.path }
|
||||
if (it.kind === 'script') {
|
||||
rewritten.content = rewriteContent(it.content ?? '', map)
|
||||
} else if (it.kind === 'raw_app') {
|
||||
rewritten.content = rewriteRawVarRefs(rewriteRawAppContent(it.content ?? '', map), map)
|
||||
} else if (it.kind === 'flow') {
|
||||
rewritten.value = rewriteVarRefsInValue(rewriteFlowValue(it.value, map), map)
|
||||
} else if (it.kind === 'app') {
|
||||
rewritten.value = rewriteVarRefsInValue(rewriteAppValue(it.value, map), map)
|
||||
}
|
||||
return rewritten
|
||||
})
|
||||
|
||||
const resourceStubs: ResourceStub[] = []
|
||||
const resolved = await Promise.all(
|
||||
[...resourcePaths].map(async (path) => ({ path, type: await deps.resolveResourceType(path) }))
|
||||
)
|
||||
for (const { path, type } of resolved) {
|
||||
if (!type) {
|
||||
unresolved.push(path)
|
||||
continue
|
||||
}
|
||||
resourceStubs.push({ originalPath: path, newPath: map.get(path) ?? path, resource_type: type })
|
||||
}
|
||||
|
||||
// `unresolved` keys missing items by kind:path but stores the bare path, so a
|
||||
// missing script and flow (or a runnable and resource) sharing a path can push
|
||||
// the same string twice. Dedupe: callers use it as a display/blocker list where
|
||||
// duplicate keys would break keyed rendering.
|
||||
return { items, resourceStubs, pathMap: map, unresolved: [...new Set(unresolved)] }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { refContainmentViolation, varContainmentViolation } from './projectInstall'
|
||||
import type { Ref } from './projectBundle'
|
||||
|
||||
describe('refContainmentViolation', () => {
|
||||
const folder = 'proj'
|
||||
const violation = (r: Ref) => refContainmentViolation([r], folder)
|
||||
|
||||
it('allows references relocated into the target folder', () => {
|
||||
expect(violation({ kind: 'resource', path: 'f/proj/db' })).toBeUndefined()
|
||||
expect(violation({ kind: 'script', path: 'f/proj/helper' })).toBeUndefined()
|
||||
expect(violation({ kind: 'flow', path: 'f/proj/sub' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('allows hub script/flow references but never hub resources', () => {
|
||||
expect(violation({ kind: 'script', path: 'hub/1/x/y' })).toBeUndefined()
|
||||
expect(violation({ kind: 'flow', path: 'hub/1/a/b' })).toBeUndefined()
|
||||
// Resources are not hub-hosted, so a hub/ resource path is still an escape.
|
||||
expect(violation({ kind: 'resource', path: 'hub/1/x/y' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects references bound to another namespace', () => {
|
||||
// The crux: an in-folder runnable pointing its resource at an existing asset.
|
||||
expect(violation({ kind: 'resource', path: 'u/admin/db' })).toContain('escapes')
|
||||
expect(violation({ kind: 'script', path: 'f/other/helper' })).toContain('escapes')
|
||||
expect(violation({ kind: 'flow', path: 'u/admin/sub' })).toContain('escapes')
|
||||
})
|
||||
|
||||
it('does not treat a prefix-only folder match as internal', () => {
|
||||
expect(violation({ kind: 'script', path: 'f/proj2/helper' })).toContain('escapes')
|
||||
})
|
||||
|
||||
it('reports the first offending reference and passes a fully-contained set', () => {
|
||||
expect(
|
||||
refContainmentViolation(
|
||||
[
|
||||
{ kind: 'resource', path: 'f/proj/db' },
|
||||
{ kind: 'script', path: 'hub/1/x/y' }
|
||||
],
|
||||
folder
|
||||
)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
refContainmentViolation(
|
||||
[
|
||||
{ kind: 'resource', path: 'f/proj/db' },
|
||||
{ kind: 'resource', path: 'u/admin/secret' }
|
||||
],
|
||||
folder
|
||||
)
|
||||
).toContain('u/admin/secret')
|
||||
})
|
||||
})
|
||||
|
||||
describe('varContainmentViolation', () => {
|
||||
const folder = 'proj'
|
||||
|
||||
it('allows in-folder variable references', () => {
|
||||
expect(varContainmentViolation({ token: '$var:f/proj/token' }, folder)).toBeUndefined()
|
||||
expect(varContainmentViolation({ x: 'no refs here' }, folder)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a `$var:` or `$jsonvar:` bound to another namespace', () => {
|
||||
// The crux: a variable arg the ref extractors miss, resolved under the perms.
|
||||
expect(varContainmentViolation({ queue_url: '$var:u/admin/token' }, folder)).toContain(
|
||||
'u/admin/token'
|
||||
)
|
||||
expect(varContainmentViolation({ cfg: '$jsonvar:f/other/secret' }, folder)).toContain('escapes')
|
||||
})
|
||||
|
||||
it('ignores a `$var:` literal embedded in inline code', () => {
|
||||
const flowValue = {
|
||||
flow_env: { API: '$var:f/proj/api_key' },
|
||||
modules: [{ value: { type: 'rawscript', content: 'return "$var:u/admin/should_not_flag"' } }]
|
||||
}
|
||||
expect(varContainmentViolation(flowValue, folder)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,405 @@
|
||||
// Imports a Hub project export into a workspace: one importer per item kind,
|
||||
// each item reported individually so one bad item never aborts the rest.
|
||||
// UI-free — the install page owns folder choice and migration review.
|
||||
|
||||
import {
|
||||
AppService,
|
||||
FlowService,
|
||||
FolderService,
|
||||
ResourceService,
|
||||
ScriptService,
|
||||
VariableService,
|
||||
WorkspaceService
|
||||
} from '$lib/gen'
|
||||
import {
|
||||
TRIGGER_KINDS,
|
||||
createWorkspaceTriggerDisabled,
|
||||
triggerHandlerRefs,
|
||||
type WorkspaceTrigger,
|
||||
type WorkspaceTriggerKind
|
||||
} from '../triggers/workspaceTriggersList'
|
||||
import { updatePolicy } from '$lib/components/apps/editor/appPolicy'
|
||||
import { updateRawAppPolicy } from '$lib/sharedUtils'
|
||||
import type { App } from '$lib/components/apps/types'
|
||||
import { runScriptAndPollResult } from '$lib/components/jobs/utils'
|
||||
import {
|
||||
classifyPath,
|
||||
collectExportVarPaths,
|
||||
extractAppRefs,
|
||||
extractFlowRefs,
|
||||
extractRawAppRefs,
|
||||
extractScriptRefs,
|
||||
extractTriggerConfigResourceRefs,
|
||||
extractVarRefsFromValue,
|
||||
retargetProjectExport,
|
||||
type ExportItem,
|
||||
type ProjectExport,
|
||||
type ProjectMigration,
|
||||
type Ref
|
||||
} from './projectBundle'
|
||||
|
||||
export interface InstallResult {
|
||||
path: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Guarding an item's own path is not enough: the `$res:`/script/flow refs baked
|
||||
// into its content are live bindings the backend acts on. A well-formed export
|
||||
// relocates them all into f/<folder>/ (hub/ script refs stay external); anything
|
||||
// else points a runnable at an existing asset in another namespace, so refuse the
|
||||
// item rather than bind it there. Resources are never hub-hosted, so a hub/ path
|
||||
// there is not a valid escape hatch. Mirrors the trigger-config containment.
|
||||
export function refContainmentViolation(refs: Ref[], folder: string): string | undefined {
|
||||
for (const r of refs) {
|
||||
const cls = classifyPath(r.path, folder)
|
||||
if (cls === 'internal') continue
|
||||
if (cls === 'hub' && r.kind !== 'resource') continue
|
||||
return `reference '${r.path}' escapes the target folder f/${folder}/ — skipped`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// `$var:`/`$jsonvar:` references (in flow static inputs, flow_env, app runnable
|
||||
// inputs, trigger config) are resolved at runtime under the imported runnable's
|
||||
// permissions and are never hub-hosted. Retargeting relocates a project's own refs
|
||||
// into the target folder; anything still outside it points at another namespace, so
|
||||
// reject those. Takes the parsed value so inline code carrying a literal is ignored.
|
||||
export function varContainmentViolation(value: any, folder: string): string | undefined {
|
||||
for (const p of extractVarRefsFromValue(value)) {
|
||||
if (classifyPath(p, folder) !== 'internal') {
|
||||
return `variable '${p}' escapes the target folder f/${folder}/ — skipped`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Surface the backend's explanation: API errors carry the real message in
|
||||
// `.body` (plain text for Windmill 4xx), while `.message` is the generic
|
||||
// status text ("Bad Request"). Prefer the body so e.g. a path/route_path
|
||||
// collision reads as the actual reason, not just "Bad Request".
|
||||
function errorMessage(e: any): string {
|
||||
const body = e?.body
|
||||
if (typeof body === 'string' && body.trim() !== '') return body
|
||||
if (body && typeof body === 'object')
|
||||
return body.error?.message ?? body.message ?? JSON.stringify(body)
|
||||
return e?.message ?? String(e)
|
||||
}
|
||||
|
||||
// Recompute an app's execution policy from its (retargeted) value, mirroring
|
||||
// what the editor does on deploy. `triggerables_v2` is keyed by
|
||||
// `<component>:rawscript/<sha256(inline content)>`; retargeting rewrites that
|
||||
// content, so a copied or empty policy would leave every inline runnable
|
||||
// "forbidden by policy" at runtime. Default to publisher (auth required).
|
||||
async function computeAppPolicy(value: any): Promise<any> {
|
||||
const policy = (await updatePolicy(value as App, undefined)) as any
|
||||
if (!policy.execution_mode) policy.execution_mode = 'publisher'
|
||||
return policy
|
||||
}
|
||||
async function computeRawAppPolicy(runnables: Record<string, any>): Promise<any> {
|
||||
const policy = (await updateRawAppPolicy(runnables, undefined)) as any
|
||||
if (!policy.execution_mode) policy.execution_mode = 'publisher'
|
||||
return policy
|
||||
}
|
||||
|
||||
function importScript(workspace: string, s: ExportItem): Promise<unknown> {
|
||||
return ScriptService.createScript({
|
||||
workspace,
|
||||
requestBody: {
|
||||
path: s.path,
|
||||
summary: s.summary ?? '',
|
||||
description: s.description ?? '',
|
||||
content: s.content ?? '',
|
||||
language: s.language,
|
||||
schema: s.schema ?? undefined,
|
||||
kind: s.kind ?? 'script',
|
||||
lock: s.lockfile ?? undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function importFlow(workspace: string, f: ExportItem): Promise<unknown> {
|
||||
return FlowService.createFlow({
|
||||
workspace,
|
||||
requestBody: {
|
||||
path: f.path,
|
||||
summary: f.summary ?? '',
|
||||
description: f.description ?? '',
|
||||
value: f.value,
|
||||
schema: f.schema ?? undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Stubs only: never overwrite an existing resource's value (updateIfExists
|
||||
// stays false so a path collision is reported as a failed item instead).
|
||||
function importResourceStub(workspace: string, r: ExportItem): Promise<unknown> {
|
||||
return ResourceService.createResource({
|
||||
workspace,
|
||||
updateIfExists: false,
|
||||
requestBody: {
|
||||
path: r.path,
|
||||
resource_type: r.resource_type,
|
||||
value: {},
|
||||
description: 'Imported stub — fill in the value.'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Variables hold secrets/config, so their values are never shipped. Create an empty
|
||||
// secret placeholder for a project variable the importer must fill, mirroring the
|
||||
// resource stubs. Conflict-safe: an already-present variable (the importer filled it,
|
||||
// or a re-import) is left untouched rather than clobbered.
|
||||
async function importVariablePlaceholder(workspace: string, path: string): Promise<void> {
|
||||
if (await VariableService.existsVariable({ workspace, path })) return
|
||||
await VariableService.createVariable({
|
||||
workspace,
|
||||
requestBody: {
|
||||
path,
|
||||
value: '',
|
||||
is_secret: true,
|
||||
description: 'Imported placeholder — fill in the value.'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function importApp(workspace: string, a: ExportItem): Promise<unknown> {
|
||||
if (a.app_type === 'raw') {
|
||||
let parsed: any
|
||||
try {
|
||||
parsed = JSON.parse(a.value?.raw ?? '{}')
|
||||
} catch (e: any) {
|
||||
throw new Error(`invalid raw app bundle: ${e?.message ?? String(e)}`)
|
||||
}
|
||||
const files = { ...(parsed.files ?? {}) }
|
||||
const js = files['/bundle.js'] ?? ''
|
||||
const css = files['/bundle.css'] ?? ''
|
||||
delete files['/bundle.js']
|
||||
delete files['/bundle.css']
|
||||
const runnables = parsed.runnables ?? {}
|
||||
return AppService.createAppRaw({
|
||||
workspace,
|
||||
formData: {
|
||||
app: {
|
||||
path: a.path,
|
||||
summary: a.summary ?? '',
|
||||
value: {
|
||||
files,
|
||||
runnables,
|
||||
// Keep the full-code app's explicit data table declaration.
|
||||
...(parsed.data !== undefined ? { data: parsed.data } : {}),
|
||||
...(parsed.datatables !== undefined ? { datatables: parsed.datatables } : {})
|
||||
},
|
||||
policy: await computeRawAppPolicy(runnables)
|
||||
},
|
||||
js,
|
||||
css
|
||||
}
|
||||
})
|
||||
}
|
||||
return AppService.createApp({
|
||||
workspace,
|
||||
requestBody: {
|
||||
path: a.path,
|
||||
summary: a.summary ?? '',
|
||||
value: a.value,
|
||||
policy: await computeAppPolicy(a.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Apply one migration to the target data table. If the data table opted into
|
||||
// migrations, record it (datatable_migrations + _wm_migrations, run only this
|
||||
// version); otherwise run the SQL once as a preview job (unrecorded).
|
||||
async function applyOneMigration(
|
||||
workspace: string,
|
||||
projectSlug: string,
|
||||
m: ProjectMigration
|
||||
): Promise<void> {
|
||||
let recorded = false
|
||||
try {
|
||||
const status = await WorkspaceService.getDatatableMigrationsStatus({
|
||||
workspace,
|
||||
datatableName: m.datatable_name
|
||||
})
|
||||
recorded = !!status.enabled
|
||||
} catch {}
|
||||
|
||||
if (recorded) {
|
||||
// Record the shipped down migration (DROP the created tables) so it can be
|
||||
// rolled back.
|
||||
const codeDown = (m.sql_down ?? '').trim()
|
||||
const created = await WorkspaceService.createDatatableMigration({
|
||||
workspace,
|
||||
datatableName: m.datatable_name,
|
||||
requestBody: {
|
||||
name: `hub_import_${projectSlug}`,
|
||||
code_up: m.sql,
|
||||
code_down: codeDown || undefined
|
||||
}
|
||||
})
|
||||
await WorkspaceService.runDatatableMigrations({
|
||||
workspace,
|
||||
datatableName: m.datatable_name,
|
||||
only: created.timestamp
|
||||
})
|
||||
} else {
|
||||
await runScriptAndPollResult({
|
||||
workspace,
|
||||
requestBody: {
|
||||
language: 'postgresql',
|
||||
content: m.sql,
|
||||
args: { database: `datatable://${m.datatable_name}` }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a project export into `workspace` under `f/<folder>/`: create the
|
||||
* folder, retarget every item, import kind by kind, then apply the (already
|
||||
* reviewed) migrations. Each item's outcome is reported through `onResult`;
|
||||
* failures never abort the remaining items.
|
||||
*/
|
||||
export async function installProject(args: {
|
||||
workspace: string
|
||||
exportData: ProjectExport
|
||||
folder: string
|
||||
migrations: ProjectMigration[]
|
||||
hasEeLicense: boolean
|
||||
onResult: (r: InstallResult) => void
|
||||
}): Promise<void> {
|
||||
const { workspace, exportData, folder, migrations, hasEeLicense, onResult } = args
|
||||
|
||||
const record = (path: string, p: Promise<unknown>): Promise<void> =>
|
||||
p.then(
|
||||
() => onResult({ path, ok: true }),
|
||||
(e: any) => onResult({ path, ok: false, error: errorMessage(e) })
|
||||
)
|
||||
|
||||
try {
|
||||
await FolderService.createFolder({ workspace, requestBody: { name: folder } })
|
||||
} catch {}
|
||||
|
||||
const proj = retargetProjectExport(exportData, exportData.project.slug, folder)
|
||||
|
||||
// The export is remote input: every path it wants to write must stay inside
|
||||
// the folder the user chose. Anything else (crafted export, or an export
|
||||
// whose items weren't relocated into f/<slug>/ at publish) is refused
|
||||
// per-item instead of being created in another namespace.
|
||||
const prefix = `f/${folder}/`
|
||||
const guard = (path: unknown, ...also: unknown[]): string | undefined => {
|
||||
for (const p of [path, ...also]) {
|
||||
if (typeof p !== 'string' || !p.startsWith(prefix)) {
|
||||
return `path '${String(p)}' escapes the target folder ${prefix} — skipped`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const checked = (path: unknown, run: () => Promise<unknown>, ...also: unknown[]) => {
|
||||
const violation = guard(path, ...also)
|
||||
return violation
|
||||
? record(String(path), Promise.reject(new Error(violation)))
|
||||
: record(String(path), run())
|
||||
}
|
||||
|
||||
// `refs` catches structured runnable/`$res:` refs; `varValue` is the parsed item
|
||||
// walked for `$var:`/`$jsonvar:` argument refs (which the ref extractors miss).
|
||||
const checkedItem = (path: unknown, refs: Ref[], varValue: any, run: () => Promise<unknown>) => {
|
||||
const violation =
|
||||
guard(path) ??
|
||||
refContainmentViolation(refs, folder) ??
|
||||
varContainmentViolation(varValue, folder)
|
||||
return violation
|
||||
? record(String(path), Promise.reject(new Error(violation)))
|
||||
: record(String(path), run())
|
||||
}
|
||||
|
||||
for (const s of proj.scripts) {
|
||||
// `$var:` is resolved in job args (flow inputs, schedule args, trigger config),
|
||||
// not in script source, so there is no variable arg to contain here.
|
||||
await checkedItem(s.path, extractScriptRefs(s.content ?? ''), undefined, () =>
|
||||
importScript(workspace, s)
|
||||
)
|
||||
}
|
||||
for (const f of proj.flows) {
|
||||
await checkedItem(f.path, extractFlowRefs(f.value), f.value, () => importFlow(workspace, f))
|
||||
}
|
||||
for (const r of proj.resources) {
|
||||
await checked(r.path, () => importResourceStub(workspace, r))
|
||||
}
|
||||
// Placeholders for the project's internal `$var:`/`$jsonvar:` refs (retargeted
|
||||
// into this folder). External refs are rejected per-item, so only stub in-folder
|
||||
// ones; guard again in case an out-of-folder ref slipped through retargeting.
|
||||
for (const p of collectExportVarPaths(proj)) {
|
||||
if (!p.startsWith(prefix)) continue
|
||||
await record(`variable: ${p}`, importVariablePlaceholder(workspace, p))
|
||||
}
|
||||
for (const a of proj.apps) {
|
||||
const isRaw = a.app_type === 'raw'
|
||||
const refs = isRaw ? extractRawAppRefs(a.value?.raw ?? '') : extractAppRefs(a.value)
|
||||
// Raw apps hold their runnables in the `value.raw` JSON string; parse it so the
|
||||
// walk sees the same structure the backend resolves. Malformed raw fails at import.
|
||||
let varValue: any = a.value
|
||||
if (isRaw) {
|
||||
try {
|
||||
varValue = JSON.parse(a.value?.raw ?? '{}')
|
||||
} catch {
|
||||
varValue = undefined
|
||||
}
|
||||
}
|
||||
await checkedItem(a.path, refs, varValue, () => importApp(workspace, a))
|
||||
}
|
||||
// A trigger's config is a live binding, not inert content: resource fields,
|
||||
// handler runnables and $res: refs it names are acted on by the backend, so
|
||||
// every one must stay inside the chosen folder (handlers may also point at
|
||||
// hub/ scripts). Otherwise a crafted export could bind the trigger to
|
||||
// existing assets in another namespace.
|
||||
const triggerConfigViolation = (t: ExportItem): string | undefined => {
|
||||
const cfg = (t.config ?? {}) as Record<string, any>
|
||||
for (const r of triggerHandlerRefs({ kind: t.kind, config: cfg } as WorkspaceTrigger)) {
|
||||
if (!r.path.startsWith(prefix) && !r.path.startsWith('hub/')) {
|
||||
return `handler '${r.path}' escapes the target folder ${prefix} — skipped`
|
||||
}
|
||||
}
|
||||
const resourceRefs = new Set(extractTriggerConfigResourceRefs(cfg))
|
||||
const field = TRIGGER_KINDS[t.kind as WorkspaceTriggerKind]?.resourceField
|
||||
const fieldValue = field ? cfg[field] : undefined
|
||||
if (typeof fieldValue === 'string' && fieldValue !== '') resourceRefs.add(fieldValue)
|
||||
for (const p of resourceRefs) {
|
||||
if (!p.startsWith(prefix)) {
|
||||
return `resource '${p}' escapes the target folder ${prefix} — skipped`
|
||||
}
|
||||
}
|
||||
// Config fields (e.g. SQS queue_url) can carry `$var:`/`$jsonvar:` refs too.
|
||||
return varContainmentViolation(cfg, folder)
|
||||
}
|
||||
for (const t of proj.triggers) {
|
||||
const violation = guard(t.path, t.runnable_path) ?? triggerConfigViolation(t)
|
||||
await record(
|
||||
String(t.path),
|
||||
violation
|
||||
? Promise.reject(new Error(violation))
|
||||
: createWorkspaceTriggerDisabled(
|
||||
workspace,
|
||||
{
|
||||
kind: t.kind,
|
||||
path: t.path,
|
||||
script_path: t.runnable_path,
|
||||
is_flow: t.runnable_kind === 'flow',
|
||||
summary: t.summary ?? null,
|
||||
config: t.config ?? null
|
||||
},
|
||||
{ hasEeLicense }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Apply the reviewed data table migrations after items exist.
|
||||
for (const m of migrations) {
|
||||
await record(
|
||||
`data table: ${m.datatable_name}`,
|
||||
applyOneMigration(workspace, exportData.project.slug, m)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// inferAssets loads WASM; stub it so script detection is deterministic and no
|
||||
// wasm init runs in the test.
|
||||
const inferAssetsMock = vi.fn()
|
||||
vi.mock('$lib/infer', () => ({ inferAssets: (...a: any[]) => inferAssetsMock(...a) }))
|
||||
|
||||
// Only getDatatableFullSchema is used by the generator; stub the whole service.
|
||||
const getDatatableFullSchemaMock = vi.fn()
|
||||
vi.mock('$lib/gen', () => ({
|
||||
WorkspaceService: {
|
||||
getDatatableFullSchema: (...a: any[]) => getDatatableFullSchemaMock(...a)
|
||||
}
|
||||
}))
|
||||
|
||||
import { detectDatatableTables, generateDatatableMigrations } from './projectMigrations'
|
||||
import type { FetchedItem } from './projectBundle'
|
||||
|
||||
describe('detectDatatableTables', () => {
|
||||
beforeEach(() => inferAssetsMock.mockReset())
|
||||
|
||||
it('collects datatable/table refs from scripts (re-parsed), flows and raw apps', async () => {
|
||||
inferAssetsMock.mockResolvedValue({
|
||||
status: 'ok',
|
||||
assets: [
|
||||
{ kind: 'datatable', path: 'main/customers' },
|
||||
{ kind: 'resource', path: 'u/admin/pg' } // ignored
|
||||
]
|
||||
})
|
||||
const items: FetchedItem[] = [
|
||||
{ kind: 'script', path: 'f/p/s', language: 'duckdb', content: 'select 1' },
|
||||
{
|
||||
kind: 'flow',
|
||||
path: 'f/p/fl',
|
||||
value: {
|
||||
modules: [
|
||||
{
|
||||
id: 'a',
|
||||
value: {
|
||||
type: 'rawscript',
|
||||
language: 'duckdb',
|
||||
content: '',
|
||||
assets: [{ kind: 'datatable', path: 'main/orders' }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
kind: 'raw_app',
|
||||
path: 'f/p/app',
|
||||
content: JSON.stringify({
|
||||
runnables: {
|
||||
r1: { inlineScript: { assets: [{ kind: 'datatable', path: 'analytics/events' }] } }
|
||||
}
|
||||
})
|
||||
}
|
||||
]
|
||||
const usage = await detectDatatableTables(items)
|
||||
expect([...(usage.get('main') ?? [])].sort()).toEqual(['customers', 'orders'])
|
||||
expect([...(usage.get('analytics') ?? [])]).toEqual(['events'])
|
||||
})
|
||||
|
||||
it('collects datatable refs from the preprocessor module', async () => {
|
||||
inferAssetsMock.mockResolvedValue({ status: 'ok', assets: [] })
|
||||
const items: FetchedItem[] = [
|
||||
{
|
||||
kind: 'flow',
|
||||
path: 'f/p/fl',
|
||||
value: {
|
||||
modules: [],
|
||||
preprocessor_module: {
|
||||
id: 'pre',
|
||||
value: {
|
||||
type: 'rawscript',
|
||||
language: 'duckdb',
|
||||
content: '',
|
||||
assets: [{ kind: 'datatable', path: 'main/inbox' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
const usage = await detectDatatableTables(items)
|
||||
expect([...(usage.get('main') ?? [])]).toEqual(['inbox'])
|
||||
})
|
||||
|
||||
it('records a datatable used with no specific table', async () => {
|
||||
inferAssetsMock.mockResolvedValue({
|
||||
status: 'ok',
|
||||
assets: [{ kind: 'datatable', path: 'main' }]
|
||||
})
|
||||
const usage = await detectDatatableTables([
|
||||
{ kind: 'script', path: 'f/p/s', language: 'duckdb', content: 'x' }
|
||||
])
|
||||
expect(usage.has('main')).toBe(true)
|
||||
expect(usage.get('main')?.size).toBe(0)
|
||||
})
|
||||
|
||||
it('reads a full-code app’s explicit data.tables declaration', async () => {
|
||||
const items: FetchedItem[] = [
|
||||
{
|
||||
kind: 'raw_app',
|
||||
path: 'f/p/app',
|
||||
content: JSON.stringify({
|
||||
runnables: {},
|
||||
data: {
|
||||
datatable: 'main',
|
||||
schema: 'app1',
|
||||
tables: ['main/customers', 'main/app1:orders']
|
||||
}
|
||||
})
|
||||
}
|
||||
]
|
||||
const usage = await detectDatatableTables(items)
|
||||
// public-schema ref keeps the bare name; non-public keeps schema.table.
|
||||
expect([...(usage.get('main') ?? [])].sort()).toEqual(['app1.orders', 'customers'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateDatatableMigrations', () => {
|
||||
beforeEach(() => getDatatableFullSchemaMock.mockReset())
|
||||
|
||||
const schema = {
|
||||
public: {
|
||||
customers: {
|
||||
name: 'customers',
|
||||
columns: [
|
||||
{ name: 'id', datatype: 'integer', primary_key: true, nullable: false },
|
||||
{ name: 'email', datatype: 'text', nullable: true }
|
||||
],
|
||||
foreign_keys: []
|
||||
},
|
||||
orders: {
|
||||
name: 'orders',
|
||||
columns: [
|
||||
{ name: 'id', datatype: 'integer', primary_key: true, nullable: false },
|
||||
{ name: 'customer_id', datatype: 'integer', nullable: false }
|
||||
],
|
||||
foreign_keys: [
|
||||
{
|
||||
target_table: 'public.customers',
|
||||
columns: [{ source_column: 'customer_id', target_column: 'id' }],
|
||||
on_delete: 'NO ACTION',
|
||||
on_update: 'NO ACTION'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('creates referenced tables in FK-dependency order in one transaction, enabled', async () => {
|
||||
getDatatableFullSchemaMock.mockResolvedValue(schema)
|
||||
const usage = new Map([['main', new Set(['orders', 'customers'])]])
|
||||
const migrations = await generateDatatableMigrations('ws', usage)
|
||||
expect(migrations).toHaveLength(1)
|
||||
const m = migrations[0]
|
||||
expect(m.datatable_name).toBe('main')
|
||||
expect(m.enabled).toBe(true)
|
||||
expect(m.sql.startsWith('BEGIN;')).toBe(true)
|
||||
expect(m.sql.trimEnd().endsWith('COMMIT;')).toBe(true)
|
||||
// customers (FK target) must be created before orders (FK source).
|
||||
expect(m.sql.indexOf('"public"."customers"')).toBeLessThan(m.sql.indexOf('"public"."orders"'))
|
||||
// A single wrapping transaction, not one per table.
|
||||
expect(m.sql.match(/BEGIN;/g)?.length).toBe(1)
|
||||
// Idempotent: won't abort if a pulled-in parent already exists in the target.
|
||||
expect(m.sql).toContain('CREATE TABLE IF NOT EXISTS "public"."customers"')
|
||||
// Down migration lists drops commented out (nothing dropped by default),
|
||||
// in reverse order: orders (child) before customers (parent).
|
||||
expect(m.sql_down).toContain('-- DROP TABLE IF EXISTS "public"."orders";')
|
||||
expect(m.sql_down).toContain('-- DROP TABLE IF EXISTS "public"."customers";')
|
||||
// No uncommented DROP TABLE anywhere.
|
||||
expect(/^\s*DROP TABLE/m.test(m.sql_down)).toBe(false)
|
||||
expect(m.sql_down.indexOf('"public"."orders"')).toBeLessThan(
|
||||
m.sql_down.indexOf('"public"."customers"')
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts schema-qualified table refs', async () => {
|
||||
getDatatableFullSchemaMock.mockResolvedValue(schema)
|
||||
const usage = new Map([['main', new Set(['public.customers'])]])
|
||||
const migrations = await generateDatatableMigrations('ws', usage)
|
||||
expect(migrations[0].enabled).toBe(true)
|
||||
expect(migrations[0].sql).toContain('"public"."customers"')
|
||||
})
|
||||
|
||||
it('leaves a qualified ref unresolved when its schema misses, never another schema\'s table', async () => {
|
||||
getDatatableFullSchemaMock.mockResolvedValue(schema)
|
||||
const usage = new Map([['main', new Set(['sales.orders'])]])
|
||||
const migrations = await generateDatatableMigrations('ws', usage)
|
||||
expect(migrations[0].sql).toContain('"sales.orders" is referenced but was not found')
|
||||
expect(migrations[0].sql).not.toContain('CREATE TABLE "')
|
||||
})
|
||||
|
||||
it('emits all CREATE TABLEs before any FK constraint so circular FKs work', async () => {
|
||||
const cyclicSchema = {
|
||||
public: {
|
||||
a: {
|
||||
name: 'a',
|
||||
columns: [
|
||||
{ name: 'id', datatype: 'integer', primary_key: true, nullable: false },
|
||||
{ name: 'b_id', datatype: 'integer', nullable: true }
|
||||
],
|
||||
foreign_keys: [
|
||||
{
|
||||
target_table: 'public.b',
|
||||
columns: [{ source_column: 'b_id', target_column: 'id' }],
|
||||
on_delete: 'NO ACTION',
|
||||
on_update: 'NO ACTION'
|
||||
}
|
||||
]
|
||||
},
|
||||
b: {
|
||||
name: 'b',
|
||||
columns: [
|
||||
{ name: 'id', datatype: 'integer', primary_key: true, nullable: false },
|
||||
{ name: 'a_id', datatype: 'integer', nullable: true }
|
||||
],
|
||||
foreign_keys: [
|
||||
{
|
||||
target_table: 'public.a',
|
||||
columns: [{ source_column: 'a_id', target_column: 'id' }],
|
||||
on_delete: 'NO ACTION',
|
||||
on_update: 'NO ACTION'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
getDatatableFullSchemaMock.mockResolvedValue(cyclicSchema)
|
||||
const usage = new Map([['main', new Set(['a', 'b'])]])
|
||||
const migrations = await generateDatatableMigrations('ws', usage)
|
||||
const sql = migrations[0].sql
|
||||
expect(sql).toContain('"public"."a"')
|
||||
expect(sql).toContain('"public"."b"')
|
||||
// Both FK constraints present, and every CREATE TABLE precedes the first one.
|
||||
expect(sql.match(/ADD CONSTRAINT/g)?.length).toBe(2)
|
||||
const lastCreate = sql.lastIndexOf('CREATE TABLE IF NOT EXISTS')
|
||||
const firstConstraint = sql.indexOf('DO $$')
|
||||
expect(lastCreate).toBeGreaterThan(-1)
|
||||
expect(firstConstraint).toBeGreaterThan(lastCreate)
|
||||
})
|
||||
|
||||
it('guards FK creation so re-running on an existing table does not abort', async () => {
|
||||
getDatatableFullSchemaMock.mockResolvedValue(schema)
|
||||
const usage = new Map([['main', new Set(['orders'])]])
|
||||
const migrations = await generateDatatableMigrations('ws', usage)
|
||||
const sql = migrations[0].sql
|
||||
// The ADD CONSTRAINT must be wrapped in a pg_constraint existence check.
|
||||
expect(sql).toContain('DO $$')
|
||||
expect(sql).toContain('SELECT 1 FROM pg_constraint')
|
||||
expect(sql).toContain(`conrelid = '"public"."orders"'::regclass`)
|
||||
// No unguarded ALTER TABLE ... ADD at the start of a line.
|
||||
expect(/^ALTER TABLE .* ADD CONSTRAINT/m.test(sql)).toBe(false)
|
||||
})
|
||||
|
||||
it('creates non-public schemas before their tables', async () => {
|
||||
const appSchema = {
|
||||
app: {
|
||||
customers: {
|
||||
name: 'customers',
|
||||
columns: [{ name: 'id', datatype: 'integer', primary_key: true, nullable: false }],
|
||||
foreign_keys: []
|
||||
}
|
||||
}
|
||||
}
|
||||
getDatatableFullSchemaMock.mockResolvedValue(appSchema)
|
||||
const usage = new Map([['main', new Set(['app.customers'])]])
|
||||
const migrations = await generateDatatableMigrations('ws', usage)
|
||||
const sql = migrations[0].sql
|
||||
expect(sql).toContain('CREATE SCHEMA IF NOT EXISTS "app";')
|
||||
expect(sql.indexOf('CREATE SCHEMA IF NOT EXISTS "app";')).toBeLessThan(
|
||||
sql.indexOf('CREATE TABLE IF NOT EXISTS "app"."customers"')
|
||||
)
|
||||
expect(sql).not.toContain('CREATE SCHEMA IF NOT EXISTS "public"')
|
||||
})
|
||||
|
||||
it('keeps same-named tables from different schemas both created', async () => {
|
||||
const twoSchemas = {
|
||||
public: {
|
||||
customers: {
|
||||
name: 'customers',
|
||||
columns: [{ name: 'id', datatype: 'integer', primary_key: true, nullable: false }],
|
||||
foreign_keys: []
|
||||
}
|
||||
},
|
||||
app: {
|
||||
customers: {
|
||||
name: 'customers',
|
||||
columns: [{ name: 'id', datatype: 'integer', primary_key: true, nullable: false }],
|
||||
foreign_keys: []
|
||||
}
|
||||
}
|
||||
}
|
||||
getDatatableFullSchemaMock.mockResolvedValue(twoSchemas)
|
||||
const usage = new Map([['main', new Set(['public.customers', 'app.customers'])]])
|
||||
const migrations = await generateDatatableMigrations('ws', usage)
|
||||
expect(migrations[0].sql).toContain('"public"."customers"')
|
||||
expect(migrations[0].sql).toContain('"app"."customers"')
|
||||
})
|
||||
|
||||
it('transitively pulls in FK-referenced tables not directly used', async () => {
|
||||
getDatatableFullSchemaMock.mockResolvedValue(schema)
|
||||
// Only `orders` is referenced; `customers` (its FK target) must still be
|
||||
// created, and before `orders`.
|
||||
const usage = new Map([['main', new Set(['orders'])]])
|
||||
const migrations = await generateDatatableMigrations('ws', usage)
|
||||
const m = migrations[0]
|
||||
expect(m.enabled).toBe(true)
|
||||
expect(m.sql).toContain('"public"."customers"')
|
||||
expect(m.sql).toContain('"public"."orders"')
|
||||
expect(m.sql.indexOf('"public"."customers"')).toBeLessThan(m.sql.indexOf('"public"."orders"'))
|
||||
})
|
||||
|
||||
it('drops a foreign key whose target is not in the schema', async () => {
|
||||
// `orders` references a `warehouses` table that no longer exists in the
|
||||
// schema: the FK must be pruned so the migration still runs.
|
||||
const schemaWithDanglingFk = {
|
||||
public: {
|
||||
orders: {
|
||||
name: 'orders',
|
||||
columns: [{ name: 'id', datatype: 'integer', primary_key: true, nullable: false }],
|
||||
foreign_keys: [
|
||||
{
|
||||
target_table: 'public.warehouses',
|
||||
columns: [{ source_column: 'id', target_column: 'id' }],
|
||||
on_delete: 'NO ACTION',
|
||||
on_update: 'NO ACTION'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
getDatatableFullSchemaMock.mockResolvedValue(schemaWithDanglingFk)
|
||||
const usage = new Map([['main', new Set(['orders'])]])
|
||||
const migrations = await generateDatatableMigrations('ws', usage)
|
||||
expect(migrations[0].enabled).toBe(true)
|
||||
expect(migrations[0].sql).toContain('"public"."orders"')
|
||||
expect(migrations[0].sql).not.toContain('warehouses')
|
||||
})
|
||||
|
||||
it('emits a disabled comment entry when a referenced table is not found', async () => {
|
||||
getDatatableFullSchemaMock.mockResolvedValue(schema)
|
||||
const usage = new Map([['main', new Set(['nonexistent'])]])
|
||||
const migrations = await generateDatatableMigrations('ws', usage)
|
||||
expect(migrations).toHaveLength(1)
|
||||
expect(migrations[0].enabled).toBe(false)
|
||||
expect(migrations[0].sql).toContain('-- Table "nonexistent" is referenced but was not found')
|
||||
expect(migrations[0].sql).not.toContain('BEGIN;')
|
||||
})
|
||||
|
||||
it('keeps found tables and comments the missing ones in one migration', async () => {
|
||||
getDatatableFullSchemaMock.mockResolvedValue(schema)
|
||||
const usage = new Map([['main', new Set(['customers', 'ghost'])]])
|
||||
const migrations = await generateDatatableMigrations('ws', usage)
|
||||
expect(migrations[0].enabled).toBe(true)
|
||||
expect(migrations[0].sql).toContain('"public"."customers"')
|
||||
expect(migrations[0].sql).toContain('-- Table "ghost" is referenced but was not found')
|
||||
// Comments precede the runnable transaction.
|
||||
expect(migrations[0].sql.indexOf('-- Table "ghost"')).toBeLessThan(
|
||||
migrations[0].sql.indexOf('BEGIN;')
|
||||
)
|
||||
})
|
||||
|
||||
it('comments a data table used with no specific table', async () => {
|
||||
getDatatableFullSchemaMock.mockResolvedValue(schema)
|
||||
const usage = new Map([['main', new Set<string>()]])
|
||||
const migrations = await generateDatatableMigrations('ws', usage)
|
||||
expect(migrations[0].enabled).toBe(false)
|
||||
expect(migrations[0].sql).toContain('no specific table was referenced')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,345 @@
|
||||
// Best-effort data table migration generation for the "project = folder" Hub
|
||||
// bundle. Detects which data tables (and tables within them) a project's
|
||||
// scripts/flows/raw apps reference via `datatable` assets, then generates a
|
||||
// `CREATE TABLE` bundle per data table from the source workspace's live schema,
|
||||
// so importing the project into another workspace can recreate those tables.
|
||||
//
|
||||
// Best-effort by design: the generated SQL is shown to the publisher and is
|
||||
// fully editable before publishing. Low-code (non-raw) apps have no persisted
|
||||
// asset list and are not scanned.
|
||||
|
||||
import { inferAssets } from '$lib/infer'
|
||||
import type { SupportedLanguage } from '$lib/common'
|
||||
import { allFlowModules } from './projectBundle'
|
||||
import { getFlowModuleAssets } from '$lib/components/assets/lib'
|
||||
import { extractDataConfig, parseDataTableRef } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import {
|
||||
apiSchemaToEditorSchema,
|
||||
generateAddedTableSql,
|
||||
type DatabaseSchema
|
||||
} from '$lib/components/datatableSchemaSql'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import type { FetchedItem } from './projectBundle'
|
||||
|
||||
export interface GeneratedMigration {
|
||||
datatable_name: string
|
||||
/** Up migration: creates the tables. */
|
||||
sql: string
|
||||
/** Down migration: drops the created tables. Best-effort, generated once and
|
||||
* editable by the publisher (not re-derived from `sql`). */
|
||||
sql_down: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// A datatable asset path is `datatable`, `datatable/table`, or
|
||||
// `datatable/schema.table` (see the SQL asset parser). The first segment is the
|
||||
// data table name; the remainder identifies a specific table (absent = whole
|
||||
// data table, no table to create).
|
||||
function parseDatatableAssetPath(path: string): { datatable: string; table?: string } {
|
||||
const slash = path.indexOf('/')
|
||||
if (slash === -1) return { datatable: path }
|
||||
const datatable = path.slice(0, slash)
|
||||
const table = path.slice(slash + 1).trim()
|
||||
return { datatable, table: table || undefined }
|
||||
}
|
||||
|
||||
function addDatatableTable(
|
||||
map: Map<string, Set<string>>,
|
||||
datatable: string,
|
||||
table: string | undefined
|
||||
): void {
|
||||
if (!datatable) return
|
||||
const set = map.get(datatable) ?? new Set<string>()
|
||||
if (table) set.add(table)
|
||||
map.set(datatable, set)
|
||||
}
|
||||
|
||||
function addUsage(map: Map<string, Set<string>>, path: string): void {
|
||||
const { datatable, table } = parseDatatableAssetPath(path)
|
||||
addDatatableTable(map, datatable, table)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a project's fetched items for data table usage and return
|
||||
* `datatable -> set of table refs` (a table ref is `table` or `schema.table`).
|
||||
* - scripts: re-parse the code with the asset parser (`inferAssets`)
|
||||
* - flows: read each module's stored `assets`
|
||||
* - full-code (raw) apps: read the explicit `data.tables` declaration; fall back
|
||||
* to `runnables[key].inlineScript.assets` for older apps
|
||||
*/
|
||||
export async function detectDatatableTables(
|
||||
items: FetchedItem[]
|
||||
): Promise<Map<string, Set<string>>> {
|
||||
const map = new Map<string, Set<string>>()
|
||||
|
||||
for (const item of items) {
|
||||
if (item.kind === 'script') {
|
||||
const res = await inferAssets(
|
||||
item.language as SupportedLanguage | undefined,
|
||||
item.content ?? ''
|
||||
)
|
||||
if (res.status === 'ok') {
|
||||
for (const a of res.assets) if (a.kind === 'datatable') addUsage(map, a.path)
|
||||
}
|
||||
} else if (item.kind === 'flow') {
|
||||
for (const mod of allFlowModules(item.value)) {
|
||||
const assets = getFlowModuleAssets(mod)
|
||||
if (assets) for (const a of assets) if (a.kind === 'datatable') addUsage(map, a.path)
|
||||
}
|
||||
} else if (item.kind === 'raw_app') {
|
||||
let parsed: any
|
||||
try {
|
||||
parsed = JSON.parse(item.content ?? '{}')
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
// Full-code apps explicitly declare the data tables/tables they use
|
||||
// (`data.tables`, refs like `main/customers` or `main/schema:table`), so
|
||||
// read that rather than parsing assets.
|
||||
const config = extractDataConfig(parsed)
|
||||
if (config) {
|
||||
for (const ref of config.tables) {
|
||||
const r = parseDataTableRef(ref)
|
||||
const table = r.table
|
||||
? r.schema && r.schema !== 'public'
|
||||
? `${r.schema}.${r.table}`
|
||||
: r.table
|
||||
: undefined
|
||||
addDatatableTable(map, r.datatable, table)
|
||||
}
|
||||
}
|
||||
// Older raw apps instead carry datatable usage as inline-script assets.
|
||||
const runnables = parsed?.runnables ?? {}
|
||||
for (const key of Object.keys(runnables)) {
|
||||
const assets = runnables[key]?.inlineScript?.assets
|
||||
if (Array.isArray(assets))
|
||||
for (const a of assets)
|
||||
if (a?.kind === 'datatable' && typeof a.path === 'string') addUsage(map, a.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// Resolve a table ref (`table` or `schema.table`) to a concrete
|
||||
// `{ schemaName, tableName }` present in the live schema, or undefined if the
|
||||
// table can't be found (dropped since, typo, …). A schema-qualified ref that
|
||||
// misses stays unresolved: falling back to a same-named table in another
|
||||
// schema would generate a migration for an unrelated table while the code
|
||||
// still references the missing one.
|
||||
function resolveTable(
|
||||
schema: DatabaseSchema,
|
||||
tableRef: string
|
||||
): { schemaName: string; tableName: string } | undefined {
|
||||
const dot = tableRef.indexOf('.')
|
||||
if (dot !== -1) {
|
||||
const schemaName = tableRef.slice(0, dot)
|
||||
const tableName = tableRef.slice(dot + 1)
|
||||
return schema[schemaName]?.[tableName] ? { schemaName, tableName } : undefined
|
||||
}
|
||||
// Bare name: find it across every schema, first match wins.
|
||||
for (const schemaName of Object.keys(schema)) {
|
||||
if (schema[schemaName][tableRef]) return { schemaName, tableName: tableRef }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
type ResolvedTable = { schemaName: string; tableName: string }
|
||||
|
||||
const tableKey = (t: ResolvedTable) => `${t.schemaName}.${t.tableName}`
|
||||
|
||||
// Grow the set of tables to create so it's closed under foreign keys: a used
|
||||
// table's FK targets (and their FK targets, transitively) are pulled in, so the
|
||||
// generated CREATE TABLEs never reference a table that isn't also created. FK
|
||||
// targets that don't resolve in this schema are left out (their FK is pruned by
|
||||
// pruneSchemaForTables).
|
||||
function expandFkClosure(schema: DatabaseSchema, seed: ResolvedTable[]): ResolvedTable[] {
|
||||
const inSet = new Map(seed.map((t) => [tableKey(t), t]))
|
||||
const queue = [...seed]
|
||||
while (queue.length > 0) {
|
||||
const t = queue.shift()!
|
||||
const fks = schema[t.schemaName]?.[t.tableName]?.foreignKeys ?? []
|
||||
for (const fk of fks) {
|
||||
const target = resolveTable(schema, fk.targetTable ?? '')
|
||||
if (target && !inSet.has(tableKey(target))) {
|
||||
inSet.set(tableKey(target), target)
|
||||
queue.push(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...inSet.values()]
|
||||
}
|
||||
|
||||
// A copy of the schema restricted to `tables`, with each table's foreign keys
|
||||
// filtered to targets that are also in `tables`. generateAddedTableSql emits every
|
||||
// FK it finds on a table, so pruning here keeps a stray FK (to a table outside the
|
||||
// migration) from making the generated SQL fail.
|
||||
function pruneSchemaForTables(schema: DatabaseSchema, tables: ResolvedTable[]): DatabaseSchema {
|
||||
const inSet = new Set(tables.map(tableKey))
|
||||
const pruned: DatabaseSchema = {}
|
||||
for (const t of tables) {
|
||||
const orig = schema[t.schemaName]?.[t.tableName]
|
||||
if (!orig) continue
|
||||
;(pruned[t.schemaName] ??= {})[t.tableName] = {
|
||||
...orig,
|
||||
foreignKeys: (orig.foreignKeys ?? []).filter((fk) => {
|
||||
const target = resolveTable(schema, fk.targetTable ?? '')
|
||||
return target != null && inSet.has(tableKey(target))
|
||||
})
|
||||
}
|
||||
}
|
||||
return pruned
|
||||
}
|
||||
|
||||
// Order tables so a table is created after the in-set tables it references via a
|
||||
// foreign key. Keyed by schema-qualified name (like the rest of the pipeline) so
|
||||
// two same-named tables in different schemas aren't collapsed. Falls back to input
|
||||
// order on a cycle so generation never hangs.
|
||||
function orderByFkDependency(schema: DatabaseSchema, tables: ResolvedTable[]): ResolvedTable[] {
|
||||
const inSet = new Set(tables.map(tableKey))
|
||||
const deps = new Map<string, Set<string>>()
|
||||
for (const t of tables) {
|
||||
const fks = schema[t.schemaName]?.[t.tableName]?.foreignKeys ?? []
|
||||
const targets = new Set<string>()
|
||||
for (const fk of fks) {
|
||||
const target = resolveTable(schema, fk.targetTable ?? '')
|
||||
if (target && tableKey(target) !== tableKey(t) && inSet.has(tableKey(target))) {
|
||||
targets.add(tableKey(target))
|
||||
}
|
||||
}
|
||||
deps.set(tableKey(t), targets)
|
||||
}
|
||||
const ordered: ResolvedTable[] = []
|
||||
const done = new Set<string>()
|
||||
const visiting = new Set<string>()
|
||||
const byKey = new Map(tables.map((t) => [tableKey(t), t]))
|
||||
const visit = (key: string) => {
|
||||
if (done.has(key) || visiting.has(key)) return
|
||||
visiting.add(key)
|
||||
for (const dep of deps.get(key) ?? []) visit(dep)
|
||||
visiting.delete(key)
|
||||
done.add(key)
|
||||
const t = byKey.get(key)
|
||||
if (t) ordered.push(t)
|
||||
}
|
||||
for (const t of tables) visit(tableKey(t))
|
||||
return ordered
|
||||
}
|
||||
|
||||
// Pull a readable one-line message out of an API error for embedding in a SQL
|
||||
// comment (collapse whitespace so it can't break out of the `--` line).
|
||||
function errorText(e: any): string {
|
||||
const body = e?.body
|
||||
const raw =
|
||||
typeof body === 'string' && body.trim()
|
||||
? body
|
||||
: body && typeof body === 'object'
|
||||
? (body.error?.message ?? body.message ?? JSON.stringify(body))
|
||||
: (e?.message ?? String(e))
|
||||
return String(raw).replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate one best-effort migration per used data table. Resolved tables (plus
|
||||
* the tables they depend on via foreign key, in FK-dependency order) become a
|
||||
* single CREATE TABLE transaction, enabled by default. Anything that couldn't be
|
||||
* auto-generated — a table not found in the schema, a data table referenced as a
|
||||
* whole, or a schema that couldn't be loaded — is written as a `--` SQL comment
|
||||
* describing the problem, so the publisher sees what's missing instead of a blank
|
||||
* entry. A migration with no runnable statements (only comments) is left disabled.
|
||||
*/
|
||||
export async function generateDatatableMigrations(
|
||||
workspace: string,
|
||||
usage: Map<string, Set<string>>
|
||||
): Promise<GeneratedMigration[]> {
|
||||
const out: GeneratedMigration[] = []
|
||||
for (const [datatable, tableRefs] of usage) {
|
||||
let schema: DatabaseSchema
|
||||
try {
|
||||
const api = await WorkspaceService.getDatatableFullSchema({
|
||||
workspace,
|
||||
requestBody: { source: `datatable://${datatable}` }
|
||||
})
|
||||
schema = apiSchemaToEditorSchema(api)
|
||||
} catch (e) {
|
||||
// Couldn't reach the schema at all: leave a commented stub explaining why,
|
||||
// so the publisher can fill it in rather than seeing a silent blank.
|
||||
out.push({
|
||||
datatable_name: datatable,
|
||||
sql:
|
||||
`-- Could not load the schema of data table "${datatable}": ${errorText(e)}\n` +
|
||||
`-- Add the CREATE TABLE statement(s) for the tables this project uses.`,
|
||||
sql_down: '',
|
||||
enabled: false
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Resolve the referenced tables; record a comment for each one we can't find
|
||||
// so a partial migration still explains what's missing.
|
||||
const resolved: ResolvedTable[] = []
|
||||
const comments: string[] = []
|
||||
for (const ref of tableRefs) {
|
||||
const t = resolveTable(schema, ref)
|
||||
if (t) resolved.push(t)
|
||||
else
|
||||
comments.push(
|
||||
`-- Table "${ref}" is referenced but was not found in data table "${datatable}"; add its CREATE TABLE manually.`
|
||||
)
|
||||
}
|
||||
if (tableRefs.size === 0) {
|
||||
comments.push(
|
||||
`-- Data table "${datatable}" is used but no specific table was referenced; nothing to generate automatically.`
|
||||
)
|
||||
}
|
||||
// Pull in the tables the referenced ones depend on via FK, then generate
|
||||
// against a schema whose FKs are restricted to this set, so the migration
|
||||
// creates everything it references and never emits a dangling FK.
|
||||
const closure = expandFkClosure(schema, resolved)
|
||||
const ordered = orderByFkDependency(schema, closure)
|
||||
const prunedSchema = pruneSchemaForTables(schema, ordered)
|
||||
// Every CREATE TABLE is emitted before any FK constraint: circular FKs have
|
||||
// no valid creation order, so constraints can only run once all tables exist.
|
||||
const creates: string[] = []
|
||||
const constraints: string[] = []
|
||||
for (const t of ordered) {
|
||||
// IF NOT EXISTS: FK closure pulls in shared parent tables (e.g. a
|
||||
// referenced `orders` drags in `customers`) that often already exist in
|
||||
// the target, so a plain CREATE would abort the whole transaction. The
|
||||
// caveat — an existing differently-shaped table is silently left as-is —
|
||||
// is acceptable for a best-effort, editable migration.
|
||||
const gen = generateAddedTableSql(
|
||||
{ schemaName: t.schemaName, tableName: t.tableName, kind: 'added' },
|
||||
prunedSchema,
|
||||
{ ifNotExists: true }
|
||||
)
|
||||
if (!gen) continue
|
||||
creates.push(gen.create)
|
||||
constraints.push(...gen.constraints)
|
||||
}
|
||||
const statements = [...creates, ...constraints]
|
||||
// Comments (the errors) go on top; the CREATE TABLE transaction, if any,
|
||||
// follows. Enabled only when there's something to run.
|
||||
const parts: string[] = []
|
||||
if (comments.length > 0) parts.push(comments.join('\n'))
|
||||
if (statements.length > 0) parts.push(`BEGIN;\n${statements.join('\n\n')}\nCOMMIT;`)
|
||||
// Best-effort down migration: the DROP TABLE statements are commented out
|
||||
// because the FK closure pulls in shared parent tables that may have
|
||||
// pre-existed in the target (dropping them would lose data the project never
|
||||
// created). The publisher uncomments the tables this migration should drop.
|
||||
const drops = [...ordered]
|
||||
.reverse()
|
||||
.map((t) => `-- DROP TABLE IF EXISTS "${t.schemaName}"."${t.tableName}";`)
|
||||
const sqlDown =
|
||||
drops.length > 0
|
||||
? `-- Rollback: uncomment the tables this migration should drop (leave shared\n` +
|
||||
`-- tables that already existed in the workspace commented out).\nBEGIN;\n${drops.join('\n')}\nCOMMIT;`
|
||||
: ''
|
||||
out.push({
|
||||
datatable_name: datatable,
|
||||
sql: parts.join('\n\n'),
|
||||
sql_down: sqlDown,
|
||||
enabled: statements.length > 0
|
||||
})
|
||||
}
|
||||
return out.sort((a, b) => a.datatable_name.localeCompare(b.datatable_name))
|
||||
}
|
||||
@@ -14,7 +14,8 @@
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
import DataTable from '$lib/components/table/DataTable.svelte'
|
||||
import Cell from '$lib/components/table/Cell.svelte'
|
||||
import { Pen, Trash, Plus } from 'lucide-svelte'
|
||||
import { Pen, Trash, Plus, UploadCloud } from 'lucide-svelte'
|
||||
import DeployToHub from '$lib/components/workspaceSettings/DeployToHub.svelte'
|
||||
import Head from '$lib/components/table/Head.svelte'
|
||||
import Row from '$lib/components/table/Row.svelte'
|
||||
import Badge from '$lib/components/common/badge/Badge.svelte'
|
||||
@@ -30,6 +31,8 @@
|
||||
let newFolderName: string = $state('')
|
||||
let folders: FolderW[] | undefined = $state(undefined)
|
||||
let folderDrawer: Drawer | undefined = $state()
|
||||
let hubDrawer: Drawer | undefined = $state()
|
||||
let publishFolderName: string = $state('')
|
||||
|
||||
async function loadFolders(): Promise<void> {
|
||||
folders = (await FolderService.listFolders({ workspace: $workspaceStore! })).map((x) => {
|
||||
@@ -88,6 +91,22 @@
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Drawer bind:this={hubDrawer} size="1100px">
|
||||
<DrawerContent
|
||||
title="Publish {publishFolderName} to Hub"
|
||||
on:close={() => {
|
||||
hubDrawer?.closeDrawer()
|
||||
publishFolderName = ''
|
||||
}}
|
||||
>
|
||||
{#if publishFolderName}
|
||||
{#key publishFolderName}
|
||||
<DeployToHub folder={publishFolderName} />
|
||||
{/key}
|
||||
{/if}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.folders}
|
||||
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
|
||||
<p class="font-bold">Unauthorized</p>
|
||||
@@ -235,6 +254,15 @@
|
||||
folderDrawer?.openDrawer()
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Publish to Hub',
|
||||
icon: UploadCloud,
|
||||
disabled: !($userStore?.is_admin || $userStore?.is_super_admin),
|
||||
action: () => {
|
||||
publishFolderName = name
|
||||
hubDrawer?.openDrawer()
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: `Delete${canWrite ? '' : ' (require owner permissions)'}`,
|
||||
icon: Trash,
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
import { goto } from '$app/navigation'
|
||||
import { workspaceStore, enterpriseLicense } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { Button, Drawer, DrawerContent } from '$lib/components/common'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import FolderPicker from '$lib/components/FolderPicker.svelte'
|
||||
import type {
|
||||
ProjectExport,
|
||||
ProjectMigration
|
||||
} from '$lib/components/workspaceSettings/projectBundle'
|
||||
import {
|
||||
installProject,
|
||||
type InstallResult
|
||||
} from '$lib/components/workspaceSettings/projectInstall'
|
||||
import MigrationSqlEditor from '$lib/components/workspaceSettings/MigrationSqlEditor.svelte'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { createAsyncConfirmationModal } from '$lib/components/common/confirmationModal/asyncConfirmationModal.svelte'
|
||||
import Portal from '$lib/components/Portal.svelte'
|
||||
import { Cloud, Download, Loader2 } from 'lucide-svelte'
|
||||
|
||||
let slug = $derived($page.url.searchParams.get('hub') ?? '')
|
||||
let workspace = $derived($workspaceStore)
|
||||
|
||||
let loading = $state(true)
|
||||
let loadError = $state<string | undefined>(undefined)
|
||||
let data = $state<ProjectExport | undefined>(undefined)
|
||||
let installing = $state(false)
|
||||
// True while the migration review/missing-datatable modals are open, before the
|
||||
// import spinner starts — keeps the Import button from launching a second import.
|
||||
let planningMigrations = $state(false)
|
||||
let results = $state<InstallResult[]>([])
|
||||
let done = $state(false)
|
||||
let folderName = $state('')
|
||||
|
||||
// When the target lacks a needed data table, "import without that migration".
|
||||
const missingDatatableModal = createAsyncConfirmationModal()
|
||||
|
||||
// Migration review drawer: preview + edit each runnable migration's SQL and
|
||||
// choose which to run, resolved linearly via `reviewResolve`.
|
||||
let reviewDrawer = $state<Drawer | undefined>()
|
||||
let reviewList = $state<
|
||||
{ datatable_name: string; sql: string; sql_down: string; run: boolean }[]
|
||||
>([])
|
||||
// Bumped per review session so the Monaco editors re-mount with the new SQL.
|
||||
let reviewGeneration = $state(0)
|
||||
let reviewResolve: ((run: boolean) => void) | undefined
|
||||
function openMigrationReview(migs: ProjectMigration[]): Promise<boolean> {
|
||||
reviewList = migs.map((m) => ({
|
||||
datatable_name: m.datatable_name,
|
||||
sql: m.sql,
|
||||
sql_down: m.sql_down ?? '',
|
||||
run: true
|
||||
}))
|
||||
reviewGeneration++
|
||||
reviewDrawer?.openDrawer()
|
||||
return new Promise((resolve) => (reviewResolve = resolve))
|
||||
}
|
||||
function closeMigrationReview(run: boolean) {
|
||||
// Capture + clear first so the `on:close` fired by closeDrawer() (which would
|
||||
// call this again with run=false) can't override an explicit Run/Skip choice.
|
||||
const resolve = reviewResolve
|
||||
reviewResolve = undefined
|
||||
reviewDrawer?.closeDrawer()
|
||||
resolve?.(run)
|
||||
}
|
||||
|
||||
let loadSeq = 0
|
||||
|
||||
$effect(() => {
|
||||
if (slug && workspace) void load()
|
||||
})
|
||||
|
||||
async function load() {
|
||||
// Token + captured slug/workspace: a slow /export for an old ?hub= must not
|
||||
// overwrite the data of a newer one once we've navigated away.
|
||||
const reqSeq = ++loadSeq
|
||||
const reqSlug = slug
|
||||
const reqWorkspace = workspace
|
||||
loading = true
|
||||
loadError = undefined
|
||||
// New slug/workspace = a fresh import session: drop the previous project's
|
||||
// outcome, otherwise project B stays disabled as "Imported" with A's
|
||||
// results, and keeps A's folder.
|
||||
data = undefined
|
||||
done = false
|
||||
results = []
|
||||
folderName = ''
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/w/${reqWorkspace}/hub/projects/${encodeURIComponent(reqSlug)}/export`,
|
||||
{ credentials: 'include', headers: { accept: 'application/json' } }
|
||||
)
|
||||
const text = await res.text()
|
||||
if (reqSeq !== loadSeq) return // a newer load() superseded this one
|
||||
if (!res.ok) throw new Error(`export ${res.status}: ${text}`)
|
||||
data = JSON.parse(text)
|
||||
if (data && !folderName) folderName = data.project.slug
|
||||
} catch (e: any) {
|
||||
if (reqSeq !== loadSeq) return
|
||||
loadError = e?.message ?? String(e)
|
||||
} finally {
|
||||
if (reqSeq === loadSeq) loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const counts = $derived(
|
||||
data
|
||||
? {
|
||||
scripts: data.scripts.length,
|
||||
flows: data.flows.length,
|
||||
apps: data.apps.length,
|
||||
resources: data.resources.length,
|
||||
triggers: data.triggers.length,
|
||||
migrations: (data.migrations ?? []).filter(
|
||||
(m) => m.enabled && (m.sql ?? '').trim() !== ''
|
||||
).length
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
|
||||
// Decide which data table migrations to run. Migrations are keyed by data table
|
||||
// name and applied only to a target data table of the same name. Returns the
|
||||
// migrations to run (with any edits the user made), an empty array when there's
|
||||
// nothing to run, or `null` when the user backs out of the whole import at the
|
||||
// missing-data-table warning.
|
||||
async function planMigrations(
|
||||
workspace: string,
|
||||
migrations: ProjectMigration[]
|
||||
): Promise<ProjectMigration[] | null> {
|
||||
const enabled = migrations.filter((m) => m.enabled && (m.sql ?? '').trim() !== '')
|
||||
if (enabled.length === 0) return []
|
||||
|
||||
let present: Set<string>
|
||||
try {
|
||||
const dts = await WorkspaceService.listDataTables({ workspace })
|
||||
present = new Set(dts.map((d) => d.name))
|
||||
} catch {
|
||||
// Can't read the target's data tables — skip migrations rather than guess.
|
||||
return []
|
||||
}
|
||||
const runnable = enabled.filter((m) => present.has(m.datatable_name))
|
||||
const missingNames = [
|
||||
...new Set(enabled.filter((m) => !present.has(m.datatable_name)).map((m) => m.datatable_name))
|
||||
]
|
||||
|
||||
// Warn about missing data tables first: confirming imports without their
|
||||
// migrations, cancelling backs out of the whole import so the user can create
|
||||
// the data table(s) and re-run.
|
||||
if (missingNames.length > 0) {
|
||||
const proceed = await missingDatatableModal.ask({
|
||||
title: 'Some data tables are missing',
|
||||
confirmationText: 'Import without them',
|
||||
children: `This project uses data table(s) "${missingNames.join(
|
||||
'", "'
|
||||
)}" that don't exist in this workspace, so their migrations will be skipped. To apply them, cancel, create the data table(s) with the same name in Workspace settings → Data tables, then re-run this import.`
|
||||
})
|
||||
if (!proceed) return null
|
||||
}
|
||||
|
||||
let toRun: ProjectMigration[] = []
|
||||
if (runnable.length > 0) {
|
||||
const run = await openMigrationReview(runnable)
|
||||
if (run) {
|
||||
toRun = reviewList
|
||||
.filter((r) => r.run && r.sql.trim() !== '')
|
||||
.map((r) => ({
|
||||
datatable_name: r.datatable_name,
|
||||
sql: r.sql,
|
||||
sql_down: r.sql_down,
|
||||
enabled: true
|
||||
}))
|
||||
}
|
||||
}
|
||||
return toRun
|
||||
}
|
||||
|
||||
async function install() {
|
||||
// Snapshot reactive state up-front: `workspace` ($derived) and `data`
|
||||
// ($state, replaced by load()) can both change mid-import on a workspace
|
||||
// switch, which would split items or mix two exports. Pin both.
|
||||
// Guard against a second click while the review modal is open (the Import
|
||||
// button isn't `installing` yet during planning, so it would otherwise be
|
||||
// clickable and start a concurrent import).
|
||||
if (installing || planningMigrations) return
|
||||
const workspace = $workspaceStore
|
||||
const exportData = data
|
||||
if (!exportData || !workspace) return
|
||||
const folder = folderName.trim() || exportData.project.slug
|
||||
// A slug/workspace switch mid-import bumps loadSeq and resets the view for
|
||||
// the new project; this import's UI writes (results/done/toast) must then
|
||||
// be dropped so they can't mark the new project as imported.
|
||||
const sessionSeq = loadSeq
|
||||
const sessionSlug = slug
|
||||
// loadSeq only advances when a NEW load starts; navigating away (workspace
|
||||
// or ?hub becoming empty) never bumps it, so also require the live
|
||||
// identity to still match the captured one.
|
||||
const current = () =>
|
||||
sessionSeq === loadSeq && slug === sessionSlug && $workspaceStore === workspace
|
||||
|
||||
// Review data table migrations first (before the import spinner), so the user
|
||||
// previews/edits and decides, then the whole import runs uninterrupted.
|
||||
planningMigrations = true
|
||||
let migrationsToRun: ProjectMigration[] | null
|
||||
try {
|
||||
migrationsToRun = await planMigrations(workspace, exportData.migrations ?? [])
|
||||
} finally {
|
||||
planningMigrations = false
|
||||
}
|
||||
// User backed out at the missing-data-table warning — abort the whole import.
|
||||
if (migrationsToRun === null) return
|
||||
// The migration review can stay open indefinitely; if the project or
|
||||
// workspace changed underneath it, confirming the stale dialog must not
|
||||
// write the old export into the old workspace (with all feedback
|
||||
// suppressed by the session guard).
|
||||
if (!current()) {
|
||||
sendUserToast('Import cancelled — the project or workspace changed during review.', true)
|
||||
return
|
||||
}
|
||||
|
||||
installing = true
|
||||
results = []
|
||||
done = false
|
||||
try {
|
||||
await installProject({
|
||||
workspace,
|
||||
exportData,
|
||||
folder,
|
||||
migrations: migrationsToRun,
|
||||
hasEeLicense: !!$enterpriseLicense,
|
||||
onResult: (r) => {
|
||||
if (current()) results = [...results, r]
|
||||
}
|
||||
})
|
||||
|
||||
if (current()) {
|
||||
done = true
|
||||
const failed = results.filter((r) => !r.ok).length
|
||||
sendUserToast(
|
||||
failed > 0
|
||||
? `Imported with ${failed} item(s) failed.`
|
||||
: `Project imported into ${workspace}.`,
|
||||
failed > 0
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
installing = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto w-full max-w-screen-md px-4 py-10">
|
||||
{#if !slug}
|
||||
<p class="text-sm text-secondary">Missing <span class="font-mono">?hub=<slug></span>.</p>
|
||||
{:else if loading}
|
||||
<div class="flex items-center gap-2 text-sm text-secondary">
|
||||
<Loader2 size={16} class="animate-spin" /> Loading project…
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<p class="text-sm text-red-600">Failed to load project: {loadError}</p>
|
||||
{:else if data}
|
||||
<h1 class="text-2xl font-semibold text-primary">Add “{data.project.name}” to workspace</h1>
|
||||
<p class="mt-1 text-sm text-secondary">{data.project.summary}</p>
|
||||
|
||||
<div class="mt-4 max-w-xs">
|
||||
<p class="mb-1 text-xs text-tertiary">
|
||||
Folder in <span class="font-mono">{workspace}</span>
|
||||
</p>
|
||||
<FolderPicker bind:folderName disabled={installing || done} size="sm" />
|
||||
<p class="mt-1 text-xs text-tertiary">
|
||||
Items import under <span class="font-mono">f/{folderName.trim() || data.project.slug}/</span
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap gap-2 text-xs">
|
||||
<span class="rounded border px-2 py-1">{counts?.scripts} scripts</span>
|
||||
<span class="rounded border px-2 py-1">{counts?.flows} flows</span>
|
||||
<span class="rounded border px-2 py-1">{counts?.apps} apps</span>
|
||||
<span class="rounded border px-2 py-1">{counts?.resources} resources</span>
|
||||
<span class="rounded border px-2 py-1">{counts?.triggers} triggers</span>
|
||||
{#if counts && counts.migrations > 0}
|
||||
<span class="rounded border px-2 py-1">{counts.migrations} data table migrations</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-4 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100"
|
||||
>
|
||||
Resources are imported as empty stubs — set their values after import; a resource whose path
|
||||
already exists is reported as failed (existing values are never overwritten). Trigger kinds
|
||||
are recreated disabled, except GCP and Azure triggers, which manage cloud subscriptions at
|
||||
creation and must be re-created manually after filling their resource. Kafka, NATS, SQS, GCP
|
||||
and Azure triggers all require Enterprise. Triggers that reference a resource depend on stubs
|
||||
imported empty, so fill in the resource value before re-enabling the trigger.
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="accent"
|
||||
startIcon={{ icon: done ? Cloud : Download }}
|
||||
disabled={installing || done || planningMigrations}
|
||||
onclick={install}
|
||||
>
|
||||
{#if installing}
|
||||
<Loader2 size={16} class="animate-spin mr-1" /> Importing…
|
||||
{:else if done}
|
||||
Imported
|
||||
{:else}
|
||||
Import to {workspace}
|
||||
{/if}
|
||||
</Button>
|
||||
{#if done}
|
||||
<Button variant="border" onclick={() => goto(`/`)}>Go to workspace</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if results.length}
|
||||
<ul class="mt-6 flex flex-col gap-1 text-xs">
|
||||
{#each results as r}
|
||||
<li class="flex items-center gap-2">
|
||||
<span class={r.ok ? 'text-emerald-600' : 'text-red-600'}>{r.ok ? '✓' : '✗'}</span>
|
||||
<span class="font-mono">{r.path}</span>
|
||||
{#if !r.ok}<span class="text-red-600">— {r.error}</span>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Portal>
|
||||
<ConfirmationModal {...missingDatatableModal.props} />
|
||||
</Portal>
|
||||
|
||||
<Drawer bind:this={reviewDrawer} size="700px" on:close={() => closeMigrationReview(false)}>
|
||||
<DrawerContent title="Data table migrations" on:close={() => closeMigrationReview(false)}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-xs text-secondary">
|
||||
This project ships migrations that recreate the data tables it uses. Review and edit the
|
||||
SQL, then choose which to run. A migration runs against the data table of the same name in
|
||||
<span class="font-mono">{workspace}</span>; if that data table has migrations enabled it is
|
||||
recorded, otherwise it runs once as a preview job.
|
||||
</p>
|
||||
{#each reviewList as m (m.datatable_name)}
|
||||
<div class="flex flex-col gap-1.5 rounded border bg-surface-secondary p-2 text-xs">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-mono text-primary">{m.datatable_name}</span>
|
||||
<Toggle bind:checked={m.run} size="xs" options={{ right: 'Run' }} />
|
||||
</div>
|
||||
{#if m.run}
|
||||
<MigrationSqlEditor
|
||||
bind:up={m.sql}
|
||||
bind:down={m.sql_down}
|
||||
generation={reviewGeneration}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button variant="border" onclick={() => closeMigrationReview(false)}>Skip migrations</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={!reviewList.some((m) => m.run && m.sql.trim() !== '')}
|
||||
onclick={() => closeMigrationReview(true)}
|
||||
>
|
||||
Run selected
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
Reference in New Issue
Block a user