Windmillに新しいTriggerCrud型のトリガーを追加する際、バックエンドからCLI、画面、同期やキャプチャ機能まで必要な変更箇所を確認できる開発チェックリストです。
- 新しい外部サービスのトリガーを追加したいとき
- 実装箇所の抜け漏れを点検したいとき
- トリガー追加のPR前に検証したいとき
複数の独立したスクリプトを入出力データでつなぎ、注釈による実行条件やテーブルへの書き込みを設定してデータパイプラインを組み立てます。
MUST use when creating or modifying a data pipeline, a set of scripts marked `pipeline` and wired together by `on` / `materialize` annotations.
実行スコア 未検証
インストール方法を見るWindmillで、独立したスクリプトをデータの入出力でつなぐパイプラインを作成・変更します。各スクリプトの先頭に pipeline、on、materialize などの注釈を記し、何をきっかけに動くか、どのデータを出力するかを定義します。表形式データの保存先にはDuckDBとDuckLakeを基本として使い、必要に応じてPostgresのデータテーブルやS3オブジェクトも扱います。スクリプトを同じフォルダーに置き、ローカルでグラフを確認したり、パイプラインをプレビュー・実行したりする手順も含みます。
外部サービスからの取り込み、データの変換、後続処理への受け渡しを複数のスクリプトで構成したいときに向いています。定期実行や、上流のテーブルができたときに次の処理を動かす仕組みも設計できます。
DuckLakeを使うには、ワークスペースにオブジェクトストレージとDuckLakeカタログの設定が必要です。スクリプトを作成しただけでは稼働せず、デプロイとトリガーの設定が別途必要です。
この紹介文は、公開されている SKILL.md をもとに AI(OpenAI Codex)が作成しました。正確な仕様は下の原文を確認してください。
インストールする前に、エージェントに与えられる指示の中身を確認できます。
A data pipeline is NOT a flow. A flow is one runnable that orchestrates steps internally. A data pipeline is a set of independent scripts, each deployed on its own, that form a DAG by reading and writing shared storage assets (DuckLake tables, data tables, S3 objects, volumes, resources) and by declaring execution triggers. The pipeline is visualized and edited at /pipeline/<folder>; every node is a normal workspace script that happens to carry pipeline annotations. When the user asks for a "data pipeline" (or to "ingest / transform / materialize" data across steps), build pipeline-annotated scripts — do NOT build a flow.
A pipeline node that produces a table should almost always be a duckdb node that materializes its output into a DuckLake table with -- materialize ducklake://<name>/<table> (in a DuckDB node the annotation uses SQL -- comment syntax; write the body as a bare SELECT and let the runtime do the write). DuckLake is the default lakehouse store for pipelines and is the shape the pipeline editor is built around, so prefer it unless the work specifically calls for something else:
postgresql / data tables — only for row-level, OLTP-style mutations against an existing Postgres data table (frequent single-row upserts/updates, transactional reads that an app queries live).bun / python3 — only for work that doesn't map to SQL: calling an external API, wrangling files, arbitrary glue. When such a node produces tabular data for downstream steps, write it straight into DuckLake (or a data table) from the same script — see "Ingesting from Python / TypeScript" below — rather than inventing a parallel store.Do not spread a pipeline across postgres, S3, and DuckLake when one DuckLake lake would do; a consistent DuckLake lakehouse is the goal.
A node that fetches data in code (an API, a SaaS export, a scraper) writes the rows directly into their DuckLake table with wmill.ducklake(), or into a data table with wmill.datatable(). DuckLake already stores every table as parquet files in the workspace object storage, so never stage the raw payload first — no intermediate JSON / CSV / parquet file in S3 and no second "load the raw file" node. One ingestion node, one table.
Pass the rows as a query argument (a list of dicts / array of objects is sent as JSON) and let SQL unnest them; declare the column types so the table gets a real schema:
# pipeline
# on schedule
import wmill
def main():
rows = fetch_charges() # list[dict]
lake = wmill.ducklake()
lake.query("""
CREATE OR REPLACE TABLE stripe_charges AS
SELECT unnest(from_json($rows, '[{"id":"VARCHAR","amount":"BIGINT","created":"BIGINT"}]'), recursive := true)
""", rows=rows).fetch()
// pipeline
// on schedule
import * as wmill from "windmill-client"
export async function main() {
const rows = await fetchCharges()
const lake = wmill.ducklake()
await lake`CREATE OR REPLACE TABLE stripe_charges AS
SELECT unnest(from_json(${rows}, '[{"id":"VARCHAR","amount":"BIGINT","created":"BIGINT"}]'), recursive := true)`.fetch()
}
INSERT INTO <table> SELECT ... instead of CREATE OR REPLACE to append (an incremental pull), with a one-time CREATE TABLE IF NOT EXISTS first.lake = wmill.ducklake()) and keep the SQL a string literal (Python) or a template without sql.raw (TS): that is what lets the graph record the node's write to ducklake://main/<table>. A chained wmill.ducklake().query(...) or SQL built in a variable records no output, so the downstream edge never forms.wmill.ducklake("<name>") targets a non-default catalog; wmill.datatable("<name>") writes a Postgres data table the same way (Python db.query(sql, *args) with $1 placeholders, TS the same template form) — use it only when the data belongs in Postgres (see above).-- on ducklake://main/stripe_charges.A DuckLake pipeline only runs once the workspace has object storage (S3 / Azure Blob / GCS) and a DuckLake catalog configured — DuckLake tables and s3:// assets can't be materialized or read without it. Check which DuckLake catalogs the workspace has before you build.
wmill ducklake list lists them.
Drafting the annotated scripts does not require storage, but the pipeline can't ingest, materialize, or read its assets until it exists. So if there is none (or the user hits "storage not configured" errors), say so and give the right next step by role:
Never hand back a DuckLake pipeline that cannot run without flagging the missing storage and pointing to who sets it up.
A script joins the pipeline when its source begins with the pipeline annotation as a top-of-file comment, written in the script's own comment syntax — // for TS/JS (bun), -- for SQL (DuckDB/Postgres), # for Python/Bash. So it's -- pipeline in a DuckDB node, # pipeline in a Python node, // pipeline in a bun node. Every annotation below uses that same prefix (the // shown is the TS form). All other wiring is expressed as annotation comments near the top of the file:
// on <ref> — declares an execution-DAG input (what triggers/feeds this node). <ref> is either:
ducklake://main/orders, datatable://main/users, $res:f/folder/my_resource, volume://name/path, or an S3 object (see the S3 storage-form rule below).schedule, webhook, email, kafka, mqtt, amqp, nats, postgres, sqs, gcp, or data_upload (a user-uploaded S3 file). For these the actual trigger row (cron, topic, …) is created separately; the annotation only declares the binding. data_upload is special: there is no trigger row — the node instead declares an S3Object input parameter fed by the auto-generated upload picker; it never hard-codes a key. Any language can be the data_upload node:
import wmill): def main(file: wmill.S3Object): then wmill.load_s3_file(file); TS (has import * as wmill from "windmill-client"): export async function main(file: wmill.S3Object). Qualify the type as wmill.S3Object (or add from wmill import S3Object / import { S3Object } from "windmill-client") — a bare S3Object is undefined.-- $<name> (s3object) declaration and reads it directly, so a single DuckDB node can ingest and materialize:
-- pipeline
-- on data_upload
-- materialize ducklake://main/raw_uploads
-- $file (s3object)
SELECT * FROM read_csv($file)
CREATE TABLE, a wmill.writeS3File(...), a DuckLake/datatable write. To declare a managed output explicitly, use // materialize <asset-uri>.// partitioned <daily|hourly|weekly|monthly|dynamic>, // freshness <duration> (e.g. 1h), // tag <worker-tag>, // retry <count> [delay], // data_test <kind> ... (managed DuckLake targets only — deploy rejects it beside a dbt:// one), // measure <name> = <agg> [where <pred>] and // dimension <name> = <expr> (see "Declared metrics" below).An s3:// URI's first slashes select the storage, not part of the key — get this wrong and the producer/consumer edge silently won't connect:
s3:///<key> (triple slash, empty first segment) = the default workspace storage. A downstream node reading or triggering on that object uses s3:///<key> — e.g. DuckDB -- on s3:///orders/2024.parquet and read_parquet('s3:///orders/2024.parquet').s3://<storage>/<key> (double slash, non-empty first segment) = a named secondary storage called <storage> — so s3://ingest/x means storage ingest, key x, NOT key ingest/x. Only use this when the object genuinely lives in a configured secondary storage; never invent a bucket/storage name for a default-storage object (it breaks the edge).To make the producer side visible to lineage, a Python/TS node MUST pass the S3Object form, not a bare key string: Python wmill.write_s3_file(wmill.S3Object(s3="<key>"), data) (or the import-free dict {"s3": "<key>"}), TS wmill.writeS3File({ s3: "<key>" }, data). That records the default-storage asset /<key>, which a downstream s3:///<key> reader connects to (same key both sides). A bare write_s3_file("<key>", ...) records no asset and produces no edge. Add storage="<name>" only for a named secondary storage.
The key must be a string literal — the graph parser is static and cannot follow a variable, f-string, or computed path, so write_s3_file(wmill.S3Object(s3=key_var), ...) records no edge. Inline the literal (s3="events/user_events.parquet") on both the writing and reading node. The same rule applies to every asset URI in an annotation or SDK call (ducklake://, datatable://, s3://): write them literally, not via a variable.
A MANAGED
// materializeis DuckDB-only, and its target must be a DuckLake table (ducklake://<name>/<table>). Deploy rejects aducklake://// materializeon any other language (python3,bun,postgresql). For a non-DuckDB node writing the lake, do not use// materialize— write the table itself withwmill.ducklake()(see "Ingesting from Python / TypeScript") and let the output be inferred. Useduckdbwhen a node should materialize a DuckLake table.The one target ANY language may declare (except a dbt script, whose writes come from its manifest) is a warehouse relation:
// materialize manual dbt://<warehouse>/<schema>/<name>, where<warehouse>is a warehouse the workspace configures under Settings → dbt.manualis the only mode it has — nothing generates warehouse DDL, so the node issues its own write (a postgresqlCREATE TABLE/INSERT, an SDK load, …) and the annotation records the outcome. Use it on an ingestion node whose output a dbt project reads as asource: the declared relation and the dbt model land on ONE graph node, and a downstream// on dbt://<warehouse>/<schema>/<name>fires when the ingestion node completes.
A managed // materialize ducklake://<name>/<table> tells the runtime to write the node's output table for you: write the body as a single SELECT and the runtime wraps it in the create/replace — do not also write your own CREATE TABLE / INSERT. (The opposite holds for the dbt:// target above: there the node writes its own DDL and the strategies below do not apply.) Write strategy:
// materialize <uri> append → INSERT-append rows (incremental);// materialize <uri> key=<col> → merge/upsert on <col>.// materialize manual <uri> opts out of managed writes — the script writes its own DDL and the annotation only records the output asset for lineage.
materialize pairs with partitioning for incremental pipelines: a // partitioned <daily|hourly|weekly|monthly|dynamic> node runs once per partition (append/merge into a fixed-schema table). The {partition} token, usable in any asset URI and in the body SQL, is replaced at run time by the current partition's identity string:
WHERE wm_partition(<ts_col>) = {partition}. wm_partition(ts) buckets a timestamp in exactly the identity format the runtime uses for daily/hourly/weekly/monthly, so it always matches; never hand-write a strftime format.= TIMESTAMP {partition}: the identity string is not a valid timestamp literal for hourly/weekly/monthly and errors at run time.dynamic partitioning the identity is the caller-supplied key (not a timestamp, no macro), so filter on it directly: WHERE <your_key_col> = {partition}.materialize is an output declaration on a node — not a command. There is no "materialize run".
measure / dimension)On a node that materializes a DuckLake table, // measure <name> = <aggregate> [where <predicate>] names the canonical way to aggregate that table (e.g. // measure revenue = sum(amount) where not is_refund), and // dimension <name> = <expr> names a way to slice it (e.g. // dimension region = region, // dimension month = date_trunc('month', ordered_at)). They execute nothing: they are catalogued at deploy so the editor and other agents reuse the definition instead of re-deriving it and silently disagreeing.
where clause rather than folding it into the aggregate: it is rendered as <agg> FILTER (WHERE <pred>), which is what lets two measures with different predicates share one GROUP BY.// materialize.count(*) earns nothing.f/<folder>/<name>. The folder is the pipeline.duckdb materializing into DuckLake (see "Default to DuckDB + DuckLake" above); pick postgresql, bun, or python3 only when that section says the work calls for it.// pipeline, then the // on input declarations, then the transform that writes the output.// on <that-same-uri> in the downstream node so the edge forms. Reuse exact asset paths from existing nodes rather than inventing parallel ones.Locally:
wmill script new f/<folder>/<name> <language>, then write its body;wmill pipeline show <folder> --local draws the graph from your working tree — check that every edge you meant to form is there;wmill pipeline dev <folder> live-previews the pipeline, and wmill pipeline run <folder> --local runs the cascade from local files without deploying (--dry-run prints the plan first);// on schedule only declares the binding: the schedule or trigger itself is created separately (see the schedules and triggers skills).Node f/sales/orders_ingest (runs on a schedule, materializes a DuckLake table):
-- pipeline
-- on schedule
-- materialize ducklake://main/orders
SELECT * FROM read_csv('s3:///raw/orders/*.csv')
Node f/sales/orders_daily (runs when orders is produced, writes a rollup):
-- pipeline
-- on ducklake://main/orders
-- materialize ducklake://main/orders_daily
SELECT date_trunc('day', ts) AS day, count(*) AS n
FROM ducklake.main.orders GROUP BY 1
まだレビューはありません。使ってみた感想をお寄せください。
ログインなしで書き込めます。使ってみた感想、質問、動かなかった環境などを気軽にどうぞ。
まだコメントはありません。最初のコメントを書いてみませんか。
Windmillに新しいTriggerCrud型のトリガーを追加する際、バックエンドからCLI、画面、同期やキャプチャ機能まで必要な変更箇所を確認できる開発チェックリストです。
WindmillのAIチャットを変更するときに、ツールやプロンプト、結果の返し方、コンテキスト量を整理し、評価で変更前後の挙動を比べるための指針です。
WindmillのAI生成モード向けに、実際の利用要求に沿ったベンチマークケースを作成・実行します。生成結果を決定的な検証とLLM評価で確認し、AIチャットやcopilotの変更前後を比較できます。
Windmill CLIのコマンドを使い、スクリプトやフローなどの管理、ジョブの実行履歴確認、失敗原因の調査を進めるためのリファレンスです。
モジュールのインターフェースを小さく保ちながら、内部に複雑な処理をまとめる設計を支援します。テストしやすさや変更のしやすさを考え、処理の境目を整理できます。
変更内容を確認し、対象ファイルだけを選んで、形式や長さの規則に沿ったConventional Commits形式のGitコミットを作成するスキルです。