Merge remote-tracking branch 'origin/main' into datatable-connect-as-viewer-roles

# Conflicts:
#	backend/ee-repo-ref.txt
This commit is contained in:
Ruben Fiszel
2026-10-12 21:00:00 +02:00
90 changed files with 2728 additions and 951 deletions
+124 -6
View File
@@ -32,6 +32,8 @@ export interface AppFile {
guests?: boolean;
value: any;
public?: boolean;
sandbox?: boolean;
frontend_api?: string[];
summary: string;
policy: Policy;
// Mirrors granular ACLs on the app path. Omitted from app.yaml when no
@@ -160,6 +162,100 @@ export function executionModeForPush(
}
return deployedPolicy?.execution_mode === "viewer" ? "viewer" : "publisher";
}
/** Mirrors `FRONTEND_SDK_ALLOWED_SCOPES` in the backend's `apps.rs`, which stays
* the authority: this copy only fails a push before it bundles. */
export const FRONTEND_API_SCOPES = [
"jobs:run",
"jobs:read",
"users:read",
"resources:read",
"variables:read",
"flow_conversations:read",
"flow_conversations:write",
];
export type AppIsolation = { sandbox: boolean; frontendApi: string[] };
/** Write `sandbox` and `frontend_api` on a pulled app, only when they are on, so
* an app using neither keeps the file it had. Scopes of an unsandboxed app are
* inert and stay out: a file stating them without `sandbox` does not push. */
export function markIsolationFromPolicy(app: any, rawApp: boolean) {
if (app?.["policy"]?.["sandbox"] !== true) {
return;
}
app.sandbox = true;
const scopes = app.policy.frontend_sdk_scopes;
if (rawApp && Array.isArray(scopes) && scopes.length > 0) {
app.frontend_api = scopes;
}
}
/** The isolation the tracked file states, or `undefined` when it has no `sandbox`
* key and the deployed policy carries over. A file that has the key states both
* fields: `sandbox: false` turns isolation off, and a missing `frontend_api`
* means no scopes. */
export function statedIsolation(
localApp: any,
rawApp: boolean,
): AppIsolation | undefined {
const sandbox = localApp?.["sandbox"];
const scopes = localApp?.["frontend_api"] ?? [];
if (sandbox !== undefined && typeof sandbox !== "boolean") {
throw new Error("'sandbox' must be true or false");
}
if (
!Array.isArray(scopes) ||
scopes.some((s: unknown) => typeof s !== "string")
) {
throw new Error("'frontend_api' must be a list of scopes");
}
if (scopes.length > 0 && !rawApp) {
throw new Error("'frontend_api' is only available to full-code apps");
}
const seen = new Set<string>();
for (const scope of scopes) {
if (!FRONTEND_API_SCOPES.includes(scope)) {
throw new Error(
`Invalid frontend_api scope '${scope}'. Allowed scopes: ${FRONTEND_API_SCOPES.join(", ")}`,
);
}
if (seen.has(scope)) {
throw new Error(`Duplicate frontend_api scope '${scope}'`);
}
seen.add(scope);
}
if (scopes.length > 0 && sandbox !== true) {
throw new Error(
"'frontend_api' requires 'sandbox: true': an unsandboxed app gets no frontend API token",
);
}
return sandbox === undefined ? undefined : { sandbox, frontendApi: scopes };
}
/** Whether the deployed policy already has the stated isolation. The file's two
* keys are kept out of the superset comparison against the deployed app, which
* could not see a key the file dropped. */
export function isolationUpToDate(
isolation: AppIsolation | undefined,
deployedPolicy: Policy | undefined,
rawApp: boolean,
): boolean {
if (!isolation) {
return true;
}
if ((deployedPolicy?.sandbox === true) !== isolation.sandbox) {
return false;
}
if (!rawApp) {
return true;
}
const deployed = deployedPolicy?.frontend_sdk_scopes ?? [];
return (
deployed.length === isolation.frontendApi.length &&
deployed.every((s, i) => s === isolation.frontendApi[i])
);
}
export async function pushApp(
workspace: string,
remotePath: string,
@@ -198,6 +294,7 @@ export async function pushApp(
}
const path = localPath + "app.yaml";
const localApp = (await yamlParseFile(path)) as AppFile;
const isolation = statedIsolation(localApp, false);
replaceInlineScripts(localApp.value, localPath, true);
// On create the backend applies folder defaults, so there is nothing to preserve.
@@ -210,17 +307,26 @@ export async function pushApp(
localApp,
remotePath,
executionModeForPush(localApp, deployedPolicy),
basePolicy(localApp, deployedPolicy, !!preserveFields.preserve_on_behalf_of)
basePolicy(localApp, deployedPolicy, !!preserveFields.preserve_on_behalf_of),
isolation
);
// extra_perms goes through /acls/* — strip from the body so a perms-only
// edit never bumps the app version (see applyExtraPermsDiff for details).
const { extra_perms: localPerms, ...localAppBody } = localApp as AppFile & {
const {
extra_perms: localPerms,
sandbox: _sandbox,
frontend_api: _frontendApi,
...localAppBody
} = localApp as AppFile & {
extra_perms?: Record<string, boolean>;
};
if (app) {
if (isSuperset(localAppBody, app)) {
if (
isSuperset(localAppBody, app) &&
isolationUpToDate(isolation, deployedPolicy, false)
) {
log.info(colors.green(`App ${remotePath} is up to date`));
} else {
log.info(colors.bold.yellow(`Updating app ${remotePath}...`));
@@ -269,12 +375,13 @@ export async function generatingPolicy(
app: any,
path: string,
executionMode: AppExecutionMode,
base: Policy | undefined
base: Policy | undefined,
isolation?: AppIsolation
) {
log.info(colors.gray(`Generating fresh policy for app ${path}...`));
try {
app.policy = await windmillUtils.updatePolicy(app.value, base);
finalizeDerivedPolicy(app.policy, executionMode);
finalizeDerivedPolicy(app.policy, executionMode, isolation);
} catch (e) {
log.error(colors.red(`Error generating policy for app ${path}: ${e}`));
throw e;
@@ -332,10 +439,21 @@ export function preserveOnBehalfOfFields(
* deployed app would keep being able to run runnables this push removed. */
export function finalizeDerivedPolicy(
policy: Policy,
executionMode: AppExecutionMode
executionMode: AppExecutionMode,
isolation?: AppIsolation,
rawApp: boolean = false
) {
policy.triggerables = undefined;
policy.execution_mode = executionMode;
if (isolation) {
// `false` is sent rather than left out: the server reads an omitted value as
// "no opinion" and may sandbox the app anyway, hiding that the file was refused.
policy.sandbox = isolation.sandbox;
if (rawApp) {
policy.frontend_sdk_scopes =
isolation.frontendApi.length > 0 ? isolation.frontendApi : undefined;
}
}
}
async function list(opts: GlobalOptions & { includeDraftOnly?: boolean; json?: boolean }) {
+5 -17
View File
@@ -58,10 +58,10 @@ function conditionsFor(svelte: boolean): string[] {
/**
* Detects which frontend frameworks are present in package.json
*/
export function detectFrameworks(appDir: string): { svelte: boolean; vue: boolean } {
export function detectFrameworks(appDir: string): { svelte: boolean } {
const packageJsonPath = path.join(appDir, "package.json");
if (!fs.existsSync(packageJsonPath)) {
return { svelte: false, vue: false };
return { svelte: false };
}
try {
@@ -73,10 +73,9 @@ export function detectFrameworks(appDir: string): { svelte: boolean; vue: boolea
return {
svelte: "svelte" in allDeps,
vue: "vue" in allDeps,
};
} catch {
return { svelte: false, vue: false };
return { svelte: false };
}
}
@@ -256,17 +255,6 @@ export async function createFrameworkPlugins(appDir: string): Promise<any[]> {
plugins.push(createSveltePlugin(appDir));
}
if (frameworks.vue) {
log.info(colors.blue("🔧 Vue detected, adding vue plugin..."));
throw new Error("Vue plugin not supported yet");
// try {
// const esbuildPluginVue = await import("esbuild-plugin-vue3");
// plugins.push(esbuildPluginVue.default());
// } catch (error: any) {
// log.warn(colors.yellow(`Failed to load vue plugin: ${error.message}`));
// }
}
return plugins;
}
@@ -313,7 +301,7 @@ export async function createBundle(
// Use the entryPoint's directory if provided, otherwise fall back to cwd.
const appDir = options.entryPoint ? path.dirname(options.entryPoint) : process.cwd();
const frameworks = detectFrameworks(appDir);
const defaultEntry = (frameworks.svelte || frameworks.vue) ? "index.ts" : "index.tsx";
const defaultEntry = frameworks.svelte ? "index.ts" : "index.tsx";
const entryPoint = options.entryPoint ?? defaultEntry;
const outDir = options.outDir ?? "dist";
@@ -331,7 +319,7 @@ export async function createBundle(
// Ensure node_modules exists in the app directory
await ensureNodeModules(appDir);
// Load framework-specific plugins (svelte, vue) based on package.json
// Load framework-specific plugins (svelte) based on package.json
const frameworkPlugins = await createFrameworkPlugins(appDir);
// Ensure output directory exists
+2 -2
View File
@@ -12,10 +12,10 @@ interface BundleOptions {
}
/** Every entry point a raw app may have, most specific first, with the
* framework's preferred one promoted — Svelte and Vue mount from a `.ts`. */
* framework's preferred one promoted — Svelte mounts from a `.ts`. */
function entryPointFor(appDir: string): string {
const frameworks = detectFrameworks(appDir);
const candidates = frameworks.svelte || frameworks.vue
const candidates = frameworks.svelte
? ["index.ts", "index.tsx", "index.js"]
: ["index.tsx", "index.ts", "index.js"];
const entry = candidates.find((c) => fs.existsSync(path.join(appDir, c)));
+3 -3
View File
@@ -481,7 +481,7 @@ async function dev(opts: DevOptions, appFolder?: string) {
// Detect frameworks to determine default entry point
const frameworks = detectFrameworks(process.cwd());
const defaultEntry = frameworks.svelte || frameworks.vue
const defaultEntry = frameworks.svelte
? "index.ts"
: "index.tsx";
const entryPoint = opts.entry ?? defaultEntry;
@@ -577,7 +577,7 @@ async function dev(opts: DevOptions, appFolder?: string) {
const buildOptions = getDevBuildOptions(entryPoint, frameworks.svelte);
// Load framework-specific plugins (svelte, vue) based on package.json
// Load framework-specific plugins (svelte) based on package.json
const frameworkPlugins = await createFrameworkPlugins(appDir);
const wmillPlugin = {
@@ -1602,7 +1602,7 @@ const command = new Command()
})
.option(
"--entry <entry:string>",
"Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise)",
"Entry point file (default: index.ts for Svelte, index.tsx otherwise)",
)
.option("--no-open", "Don't automatically open the browser")
.option(
+54 -1
View File
@@ -9,6 +9,8 @@ import { GlobalOptions } from "../../types.ts";
import { createBundle, detectFrameworks } from "./bundle.ts";
import { APP_BACKEND_FOLDER } from "./app_metadata.ts";
import { loadRunnablesFromBackend } from "./raw_apps.ts";
import { inferSchema } from "../../utils/metadata.ts";
import { statedIsolation } from "./app.ts";
import {
getFolderSuffix,
hasFolderSuffix,
@@ -42,12 +44,62 @@ function validateRawAppYaml(appData: any): {
errors.push("Field 'summary' must be a string");
}
try {
statedIsolation(appData, true);
} catch (error: any) {
errors.push(error.message);
}
// Note: 'runnables' is no longer required in raw_app.yaml
// Runnables can be stored in separate files in the backend folder
return { errors, warnings };
}
const SQL_LANGUAGES = new Set([
"postgresql",
"mysql",
"bigquery",
"snowflake",
"mssql",
"oracledb",
"duckdb",
]);
// The viewer properties a `ctx` field can supply. The Inputs tab of the app
// editor hints on the same names (RawAppInlineScriptRunnable.svelte).
const VIEWER_IDENTITY_ARGS = ["username", "email", "groups"];
/**
* A SQL runnable argument named after a viewer property is most often a
* per-viewer filter. Left as a plain input it takes whatever the browser
* sends, so it has to be a `ctx` field for the server to fill it.
*/
export async function viewerIdentityInputWarnings(
runnables: Record<string, any>,
): Promise<string[]> {
const warnings: string[] = [];
for (const [id, runnable] of Object.entries(runnables)) {
const { language, content } = runnable?.inlineScript ?? {};
if (!SQL_LANGUAGES.has(language) || typeof content !== "string") {
continue;
}
const { schema } = await inferSchema(language, content, {}, id);
for (const arg of VIEWER_IDENTITY_ARGS) {
const field = runnable.fields?.[arg];
const serverFilled = field?.ctx !== undefined ||
field?.value !== undefined;
if (arg in (schema?.properties ?? {}) && !serverFilled) {
warnings.push(
`Runnable '${id}': argument '${arg}' is a frontend input, so a viewer can send any value. ` +
`If it identifies the viewer, set 'fields: { ${arg}: { ctx: ${arg} } }' in backend/${id}.yaml (next to 'type: inline') so the server fills it.`,
);
}
}
}
return warnings;
}
/**
* Validates that runnables exist either in backend/*.yaml files or in raw_app.yaml
*/
@@ -77,6 +129,7 @@ async function validateRunnables(
" - Or a 'runnables' field in raw_app.yaml (legacy format)",
);
} else if (hasBackendRunnables) {
warnings.push(...await viewerIdentityInputWarnings(runnablesFromBackend));
log.info(
colors.gray(
` Found ${
@@ -114,7 +167,7 @@ async function validateBuild(
// Try to create a bundle - this will validate that all dependencies are in place
const frameworks = detectFrameworks(appDir);
const entryFile = frameworks.svelte || frameworks.vue ? "index.ts" : "index.tsx";
const entryFile = frameworks.svelte ? "index.ts" : "index.tsx";
const entryPoint = path.join(appDir, entryFile);
await createBundle({
entryPoint,
+3 -34
View File
@@ -85,21 +85,6 @@ const app = mount(App, { target: document.getElementById("root")! });
export default app;
`;
const appVue = `<template>
<h1>Hello {{ msg }}</h1>
</template>
<script setup>
import { ref } from 'vue';
const msg = ref('world');
</script>`;
const indexVue = `import { createApp } from 'vue'
import App from './App.vue'
import "./index.css";
createApp(App).mount('#root')`;
const indexCss = `body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@@ -171,21 +156,6 @@ const templates: Record<string, FrameworkTemplate> = {
"svelte": "^5.55.5",
"windmill-client": "^1"
}
}`,
},
},
vue: {
name: "Vue 3",
files: {
"/index.ts": indexVue,
"/App.vue": appVue,
"/index.css": indexCss,
"/package.json": `{
"dependencies": {
"core-js": "3.26.1",
"vue": "3.5.13",
"windmill-client": "^1"
}
}`,
},
},
@@ -251,7 +221,7 @@ interface DataConfig {
schema?: string;
}
type FrameworkKey = "react19" | "react18" | "svelte5" | "vue";
type FrameworkKey = "react19" | "react18" | "svelte5";
interface NewAppOptions extends GlobalOptions {
/** App summary (short description). Skips the prompt when provided. */
@@ -384,7 +354,7 @@ async function newApp(opts: NewAppOptions) {
}
// Ask for framework (skipped if --framework is provided)
const VALID_FRAMEWORKS: FrameworkKey[] = ["react19", "react18", "svelte5", "vue"];
const VALID_FRAMEWORKS: FrameworkKey[] = ["react19", "react18", "svelte5"];
let framework: string;
if (opts.framework !== undefined) {
if (!VALID_FRAMEWORKS.includes(opts.framework)) {
@@ -403,7 +373,6 @@ async function newApp(opts: NewAppOptions) {
{ name: "React 19 (Recommended)", value: "react19" },
{ name: "React 18", value: "react18" },
{ name: "Svelte 5", value: "svelte5" },
{ name: "Vue 3", value: "vue" },
],
});
}
@@ -900,7 +869,7 @@ const command = new Command()
)
.option(
"--framework <framework:string>",
"Framework template: react19 | react18 | svelte5 | vue. Skips the prompt when provided. Triggers non-interactive mode."
"Framework template: react19 | react18 | svelte5. Skips the prompt when provided. Triggers non-interactive mode."
)
.option(
"--datatable <datatable:string>",
+19 -4
View File
@@ -20,13 +20,16 @@ import { deepEqual, readTextFile } from "../../utils/utils.ts";
import {
type AppExecutionMode,
type AppIsolation,
basePolicy,
executionModeForPush,
finalizeDerivedPolicy,
isolationUpToDate,
markAccessFromPolicy,
preserveOnBehalfOfFields,
replaceInlineScripts,
repopulateFields,
statedIsolation,
} from "./app.ts";
import type { PermissionedAsContext } from "../../core/permissioned_as.ts";
import { buildPermissionedAsContext } from "../../core/permissioned_as.ts";
@@ -49,6 +52,8 @@ export interface AppFile {
runnables?: any;
custom_path?: string;
public?: boolean;
sandbox?: boolean;
frontend_api?: string[];
summary: string;
policy: Policy;
data?: {
@@ -402,6 +407,7 @@ export async function pushRawApp(
}
const appFilePath = localPath + "raw_app.yaml";
const localApp = (await yamlParseFile(appFilePath)) as AppFile;
const isolation = statedIsolation(localApp, true);
// Load runnables from separate YAML files in the backend folder
// Falls back to reading from raw_app.yaml if no separate files exist (backward compat)
@@ -454,6 +460,7 @@ export async function pushRawApp(
deployedPolicy,
!!preserveFields.preserve_on_behalf_of,
),
isolation,
);
const files = await collectAppFiles(localPath);
@@ -461,7 +468,7 @@ export async function pushRawApp(
log.info(colors.yellow.bold(`Creating raw app ${remotePath} bundle...`));
// Detect frameworks to determine entry point
const frameworks = detectFrameworks(localPath);
const entryFile = frameworks.svelte || frameworks.vue
const entryFile = frameworks.svelte
? "index.ts"
: "index.tsx";
const entryPoint = localPath + entryFile;
@@ -485,14 +492,21 @@ export async function pushRawApp(
// to `<path>.raw_app.json`, not `<path>.app.json`. The backend granular_acls
// handler routes "raw_app" to the `app` table (where v2 raw apps actually
// live) while still dispatching DeployedObject::RawApp for git-sync.
const { extra_perms: localPerms, ...localAppNoPerms } = localApp as AppFile & {
const {
extra_perms: localPerms,
sandbox: _sandbox,
frontend_api: _frontendApi,
...localAppNoPerms
} = localApp as AppFile & {
extra_perms?: Record<string, boolean>;
};
if (app) {
// Check both metadata/runnables AND files for changes
// Files need separate comparison because isSuperset only checks if local keys exist in remote
const metadataUpToDate = isSuperset({ ...localAppNoPerms, runnables }, app);
const metadataUpToDate =
isSuperset({ ...localAppNoPerms, runnables }, app) &&
isolationUpToDate(isolation, deployedPolicy, true);
const filesUpToDate = deepEqual(files, app.value?.files);
if (metadataUpToDate && filesUpToDate) {
log.info(colors.green(`App ${remotePath} is up to date`));
@@ -560,11 +574,12 @@ export async function generatingPolicy(
path: string,
executionMode: AppExecutionMode,
base: Policy | undefined,
isolation?: AppIsolation,
) {
log.info(colors.gray(`Generating fresh policy for app ${path}...`));
try {
app.policy = await windmillUtils.updateRawAppPolicy(app.runnables, base);
finalizeDerivedPolicy(app.policy, executionMode);
finalizeDerivedPolicy(app.policy, executionMode, isolation, true);
} catch (e) {
log.error(colors.red(`Error generating policy for app ${path}: ${e}`));
throw e;
+3 -1
View File
@@ -149,7 +149,7 @@ import {
extractCurrentMapping,
} from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts";
import { generateFlowLockInternal } from "../flow/flow_metadata.ts";
import { markAccessFromPolicy } from "../app/app.ts";
import { markAccessFromPolicy, markIsolationFromPolicy } from "../app/app.ts";
import {
APP_BACKEND_FOLDER,
generateAppLocksInternal,
@@ -1359,6 +1359,7 @@ export function ZipFSElement(
}
markAccessFromPolicy(app);
markIsolationFromPolicy(app, false);
app.policy = undefined;
yield {
isDirectory: false,
@@ -1377,6 +1378,7 @@ export function ZipFSElement(
throw error;
}
markAccessFromPolicy(rawApp);
markIsolationFromPolicy(rawApp, true);
// console.log("rawApp", rawApp);
rawApp.policy = undefined;
// custom_path is derived from the file path, don't store it
+72 -14
View File
@@ -1106,6 +1106,9 @@ async sleep(seconds: number): Promise<void>
* \`fn\`'s result is encoded as JSON and decoded back before it is returned, so
* the round that runs the body sees the same types every replay sees: a \`Date\`
* comes back as a string, a \`Map\` as \`{}\`. {@link Jsonified} is that shape.
*
* Called from inside a task body, \`fn\` runs in place and nothing is
* checkpointed: a task is a single job and is never replayed.
*/
async step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>>
@@ -1954,6 +1957,9 @@ async sleep(seconds: number): Promise<void>
* \`fn\`'s result is encoded as JSON and decoded back before it is returned, so
* the round that runs the body sees the same types every replay sees: a \`Date\`
* comes back as a string, a \`Map\` as \`{}\`. {@link Jsonified} is that shape.
*
* Called from inside a task body, \`fn\` runs in place and nothing is
* checkpointed: a task is a single job and is never replayed.
*/
async step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>>
@@ -2908,6 +2914,9 @@ async sleep(seconds: number): Promise<void>
* \`fn\`'s result is encoded as JSON and decoded back before it is returned, so
* the round that runs the body sees the same types every replay sees: a \`Date\`
* comes back as a string, a \`Map\` as \`{}\`. {@link Jsonified} is that shape.
*
* Called from inside a task body, \`fn\` runs in place and nothing is
* checkpointed: a task is a single job and is never replayed.
*/
async step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>>
@@ -5091,7 +5100,9 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]]
#
# - **v2 (inside @workflow)**: dispatches as a checkpoint step.
# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.
# - **Standalone**: executes the function body directly.
# - **Standalone, or called from inside another v2 task**: executes the
# function body directly. A task runs as a single job, so the tasks it
# calls run in that same job, without their own options.
#
# A task runs as its own job, so its result is always encoded as JSON and
# decoded back before the caller sees it: a \`\`datetime\`\` comes back as a
@@ -5177,12 +5188,15 @@ def workflow(func)
# \`\`fn\`\`'s result is encoded as JSON and decoded back before it is returned,
# so the round that runs the body sees the same types every replay sees:
# a \`\`datetime\`\` comes back as a string, a tuple as a list.
#
# Called from inside a task body, \`\`fn\`\` runs in place and nothing is
# checkpointed: a task is a single job and is never replayed.
async def step(name: str, fn)
# Server-side sleep — suspend the workflow for the given duration without holding a worker.
#
# Inside a @workflow, the parent job suspends and auto-resumes after \`\`seconds\`\`.
# Outside a workflow, falls back to \`\`asyncio.sleep\`\`.
# Outside a workflow, or inside a task body, falls back to \`\`asyncio.sleep\`\`.
async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
@@ -5629,6 +5643,27 @@ Name the parameters by adding comments before the statement:
SELECT * FROM users WHERE name = ? AND age > ?;
\`\`\`
## Array parameters
Declare a list with a \`[]\` suffix on the element type (\`(varchar[])\`,
\`(int[])\`), or \`(array)\` for an untyped one. Snowflake cannot bind an array
directly, so Windmill binds the list as JSON text: wrap the placeholder with
\`PARSE_JSON(?)\`, and cast to \`ARRAY\` where a function expects one.
\`\`\`sql
-- ? groups (varchar[])
SELECT o.* FROM orders o
WHERE ARRAY_CONTAINS(o.owner_group::VARIANT, PARSE_JSON(?)::ARRAY);
\`\`\`
Or join on the elements with \`FLATTEN\`:
\`\`\`sql
-- ? groups (varchar[])
SELECT o.* FROM orders o
JOIN TABLE(FLATTEN(input => PARSE_JSON(?))) g ON g.value::STRING = o.owner_group;
\`\`\`
## Receiving an S3Object as a script parameter
Declare the arg with type \`(s3object)\`. Windmill renders an S3 file picker for
@@ -6369,7 +6404,7 @@ You need three values to run the command:
1. **summary** — a short description of the app
2. **path** — the windmill path, e.g. \`f/folder/my_app\` or \`u/username/my_app\`
3. **framework** — one of \`react19\` (recommended), \`react18\`, \`svelte5\`, \`vue\`
3. **framework** — one of \`react19\` (recommended), \`react18\`, \`svelte5\`
**summary and path**: if the user's request did not supply them, ask. Do not guess values and do not invent paths.
@@ -6441,7 +6476,7 @@ my_app{{RAW_APP_SUFFIX}}/
├── DATATABLES.md # Database schemas (run 'wmill app generate-agents' to refresh)
├── raw_app.yaml # App configuration (summary, path, data settings)
├── index.tsx # Frontend entry point
├── App.tsx # Main React/Svelte/Vue component
├── App.tsx # Main React/Svelte component
├── index.css # Styles
├── package.json # Frontend dependencies
├── wmill.ts # Auto-generated backend type definitions (DO NOT EDIT)
@@ -6599,7 +6634,7 @@ For the rest, tell the user which command fits their intent and let them run it
# Windmill Raw Apps
Raw apps let you build custom frontends with React, Svelte, or Vue that connect to Windmill backend runnables and datatables.
Raw apps let you build custom frontends with React or Svelte that connect to Windmill backend runnables and datatables.
## App shape
@@ -6615,7 +6650,7 @@ A new app gets the setup Windmill's own new-app dialog gives one: a framework, t
### Framework
Use the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18, Svelte 5 and Vue are for when they are asked for.
Use the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18 and Svelte 5 are for when they are asked for. These three are the only frameworks the raw app bundler builds. When the user asks to build or convert an app in another framework, write no code in it: say it is not available and offer these three instead.
### Data setup
@@ -6645,7 +6680,7 @@ Then build what was chosen: the data table and schema become the app's \`data.da
### Entrypoint
The entrypoint is \`index.tsx\` for React and \`index.ts\` for Svelte and Vue. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty \`<div id="root">\` and auto-renders nothing, so the entrypoint must mount a top-level \`App\` itself. Keep the UI in \`App.tsx\` / \`App.svelte\` / \`App.vue\` and keep the entrypoint as the mount shim.
The entrypoint is \`index.tsx\` for React and \`index.ts\` for Svelte. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty \`<div id="root">\` and auto-renders nothing, so the entrypoint must mount a top-level \`App\` itself. Keep the UI in \`App.tsx\` / \`App.svelte\` and keep the entrypoint as the mount shim.
React (\`index.tsx\`):
@@ -6657,7 +6692,7 @@ import App from './App'
createRoot(document.getElementById('root')!).render(<App />)
\`\`\`
Svelte (\`index.ts\`): \`mount(App, { target: document.getElementById('root')! })\`. Vue (\`index.ts\`): \`createApp(App).mount('#root')\`.
Svelte (\`index.ts\`): \`mount(App, { target: document.getElementById('root')! })\`.
**Never replace the entrypoint with a bare component** (\`export default function App() { ... }\` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts \`App\` into \`#root\`.
@@ -6823,6 +6858,18 @@ A draft app is reachable by nobody; deploying is what exposes it, and its backen
Either one lets those people run the app's backend runnables, so never open an app up unless the user asks.
\`public: true\` in \`raw_app.yaml\` deploys the app for anonymous users, and \`guests: true\` for guests. Remove the line and the next push closes the app again.
## Sandbox isolation and frontend API access
By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are \`jobs:run\`, \`jobs:read\`, \`users:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls.
In \`raw_app.yaml\`:
\`\`\`yaml
sandbox: true
frontend_api: [jobs:run, users:read]
\`\`\`
A file with a \`sandbox\` key states both settings: \`sandbox: false\` turns isolation off, and a missing \`frontend_api\` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing \`sandbox: true\` does not unsandbox it. \`frontend_api\` needs \`sandbox: true\`; the push fails otherwise.
## Data Tables
Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the \`wmill\` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table.
@@ -7455,6 +7502,8 @@ async def enrich(customer_id: str):
In TypeScript, prefer assigning each task to a named top-level const. In Python, prefer top-level async functions decorated with \`@task()\` or \`@task\`.
Only the workflow body dispatches steps. A task runs as a single job, so a task or \`step()\` called from inside a task body runs in place in that job, as a plain function call: no child job, no checkpoint, and its task options (retry, timeout, tag) do not apply. \`sleep()\` there waits in the job, and \`taskScript()\` / \`taskFlow()\` / \`waitForApproval()\` (\`task_script()\` / \`task_flow()\` / \`wait_for_approval()\`) throw. To get separate jobs, call the tasks one after another from the workflow body and pass results between them.
For existing scripts:
\`\`\`typescript
@@ -7620,8 +7669,9 @@ export async function getResumeUrls(approver?: string, flowLevel?: boolean): Pro
* const call_api = task(fetchOrders, { retry: { attempts: 3, delay: 30, multiplier: 2 } });
*
* Inside a \`workflow()\`, calling a task dispatches it as a step.
* Outside a workflow, the function body executes directly and
* {@link TaskOptions} — retry included — does not apply.
* Outside a workflow, or called from inside another task, the function body
* executes directly and {@link TaskOptions} — retry included — does not apply:
* a task runs as a single job, so the tasks it calls run in that same job.
*
* A task runs as its own job, so its result is always encoded as JSON and
* decoded back before the caller sees it: a \`Date\` comes back as a string, a
@@ -7665,6 +7715,9 @@ export function workflow<T>(fn: (...args: any[]) => Promise<T>)
* \`fn\`'s result is encoded as JSON and decoded back before it is returned, so
* the round that runs the body sees the same types every replay sees: a \`Date\`
* comes back as a string, a \`Map\` as \`{}\`. {@link Jsonified} is that shape.
*
* Called from inside a task body, \`fn\` runs in place and nothing is
* checkpointed: a task is a single job and is never replayed.
*/
export async function step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>>
@@ -7763,7 +7816,9 @@ def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict
#
# - **v2 (inside @workflow)**: dispatches as a checkpoint step.
# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.
# - **Standalone**: executes the function body directly.
# - **Standalone, or called from inside another v2 task**: executes the
# function body directly. A task runs as a single job, so the tasks it
# calls run in that same job, without their own options.
#
# A task runs as its own job, so its result is always encoded as JSON and
# decoded back before the caller sees it: a \`\`datetime\`\` comes back as a
@@ -7849,12 +7904,15 @@ def workflow(func)
# \`\`fn\`\`'s result is encoded as JSON and decoded back before it is returned,
# so the round that runs the body sees the same types every replay sees:
# a \`\`datetime\`\` comes back as a string, a tuple as a list.
#
# Called from inside a task body, \`\`fn\`\` runs in place and nothing is
# checkpointed: a task is a single job and is never replayed.
async def step(name: str, fn)
# Server-side sleep — suspend the workflow for the given duration without holding a worker.
#
# Inside a @workflow, the parent job suspends and auto-resumes after \`\`seconds\`\`.
# Outside a workflow, falls back to \`\`asyncio.sleep\`\`.
# Outside a workflow, or inside a task body, falls back to \`\`asyncio.sleep\`\`.
async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
@@ -8121,7 +8179,7 @@ app related commands
- \`app dev [app_folder:string]\` - Start a development server for building apps with live reload and hot module replacement
- \`--port <port:number>\` - Port to run the dev server on (will find next available port if occupied)
- \`--host <host:string>\` - Host to bind the dev server to
- \`--entry <entry:string>\` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise)
- \`--entry <entry:string>\` - Entry point file (default: index.ts for Svelte, index.tsx otherwise)
- \`--no-open\` - Don't automatically open the browser
- \`--recording\` - Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development
- \`app lint [app_folder:string]\` - Lint a raw app folder to validate structure and buildability
@@ -8132,7 +8190,7 @@ app related commands
- \`app new\` - create a new raw app from a template
- \`--summary <summary:string>\` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode.
- \`--path <path:string>\` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode.
- \`--framework <framework:string>\` - Framework template: react19 | react18 | svelte5 | vue. Skips the prompt when provided. Triggers non-interactive mode.
- \`--framework <framework:string>\` - Framework template: react19 | react18 | svelte5. Skips the prompt when provided. Triggers non-interactive mode.
- \`--datatable <datatable:string>\` - Datatable to wire up. Without this flag in non-interactive mode, no datatable is configured.
- \`--schema <schema:string>\` - Schema to use with --datatable. If it doesn't exist yet, a sql_to_apply/ migration (CREATE SCHEMA IF NOT EXISTS) is written for it.
- \`--overwrite\` - Overwrite the target directory if it already exists, without prompting.