Derivation stateful logic
Agent skills for setting up and operating Estuary data pipelines through your AI assistant.
npx -y skills add estuary/agent-skills --skill derivation-stateful-logicAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 6 stars6 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Create an Estuary derivation with custom SQLite state (internal tables) to make per-document decisions based on accumulated history — account balances, inventory levels, approval workflows, deduplication. Derivations add complexity and cost — confirm the user wants a derivation before reaching for this. Use when user says "stateful derivation", "derivation to track balance", "transformation to approve based on state", "derive an approval workflow", "deduplication derivation", or "build a stateful derivation".
SKILL.md
12.9 KB, as published. Nobody here has run it
derivation-stateful-logic
Estuary derivation that maintains internal SQLite tables to record state, and whose lambdas query and update that state to decide what to emit.
Prereq: read derivation-basics first — in particular the stateless-vs-stateful distinction and the role of migrations. This skill is the "stateful via SQLite tables" path.
Docs:
- https://docs.estuary.dev/getting-started/tutorials/derivations_acmebank/ — the canonical stateful tutorial (deposits, withdrawals, transfer approval) with the exact pattern this skill uses
- https://docs.estuary.dev/concepts/derivations/#internal-state — the concept page on internal task state
When to use this over alternatives
- Balance / account state: debit sender if funds available, reject otherwise
- Inventory: deduct stock, mark backordered if insufficient
- Approval workflows: accumulate approvers, emit "approved" once threshold met
- Deduplication with history: filter events by ID seen-before (beyond what collection-key uniqueness gives)
- Any business logic that depends on prior events for the same key
Reach for other skills when:
- Pure aggregations (sum/count/min/max) that reductions can express →
derivation-aggregate-metrics - Merging fields across sources →
derivation-join-collections - Time-bounded sliding state (last 24h) →
derivation-windowing(which also uses SQLite internal state, but withreadDelay) - Stateless per-doc transforms →
derivation-filter-transform
How it works (one paragraph)
You declare SQLite migrations in the derive.using.sqlite.migrations list — SQL that runs once at startup to create your state tables. Your lambda then queries and updates those tables as it processes each source document, and can SELECT a derived output row or not, depending on state. Each task shard owns its own SQLite database; events are routed to shards via a keyed shuffle, so all events for the same entity (e.g. the same account) reach the same shard and see the same state.
Canonical Example — SQL
Transfer approval system: track account balances, approve transfers with sufficient funds, reject overdrafts.
Project layout
my-derivation/
├── flow.yaml
├── schema.yaml
├── init.sql # migration: create balances table
├── debit-sender.sql # lambda for sender-side (decide + debit)
└── credit-recipient.sql # lambda for recipient-side (silent credit)
schema.yaml
type: object
properties:
transfer_id:
type: string
sender:
type: string
recipient:
type: string
amount:
type: number
outcome:
type: string
enum: [approved, rejected]
reason:
type: string
sender_balance_after:
type: number
required: [transfer_id, sender, outcome]
init.sql (migration)
CREATE TABLE IF NOT EXISTS balances (
account TEXT PRIMARY KEY NOT NULL,
balance REAL NOT NULL DEFAULT 0
);
flow.yaml
Two transforms: one evaluates transfers against the sender's balance; the second credits the recipient's account on approved transfers (reading from the derivation's own output — a self-reference).
collections:
acmeCo/processed/transfer-outcomes:
writeSchema: schema.yaml
readSchema:
allOf:
- $ref: flow://write-schema
- $ref: flow://inferred-schema
key: [/transfer_id]
projections:
outcome:
location: /outcome
partition: true
derive:
using:
sqlite:
migrations:
- init.sql
transforms:
- name: debitSender
source: acmeCo/production/transfers
shuffle: { key: [/sender] }
lambda: debit-sender.sql
- name: creditRecipient
source:
name: acmeCo/processed/transfer-outcomes # self-reference!
partitions:
include:
outcome: [approved] # only approved transfers
shuffle: { key: [/recipient] }
lambda: credit-recipient.sql
Keyed shuffle is critical: /sender for debit, /recipient for credit. Each account's events go to one shard, which owns that account's balance.
debit-sender.sql
-- 1. Read current balance (default 0 if new account)
WITH current AS (
SELECT COALESCE((SELECT balance FROM balances WHERE account = $sender), 0) AS balance
)
-- 2. Emit the outcome document
SELECT
$transfer_id AS transfer_id,
$sender AS sender,
$recipient AS recipient,
$amount AS amount,
CASE WHEN current.balance >= $amount THEN 'approved' ELSE 'rejected' END AS outcome,
CASE WHEN current.balance >= $amount
THEN 'Sufficient funds'
ELSE 'Insufficient funds: balance ' || current.balance || ' < ' || $amount
END AS reason,
CASE WHEN current.balance >= $amount
THEN current.balance - $amount
ELSE current.balance
END AS sender_balance_after
FROM current;
-- 3. Deduct from the sender's balance only if approved
INSERT INTO balances (account, balance)
SELECT $sender, -$amount
WHERE (SELECT COALESCE((SELECT balance FROM balances WHERE account = $sender), 0)) >= $amount
ON CONFLICT(account) DO UPDATE SET balance = balance - $amount;
credit-recipient.sql
-- Silently credit the recipient; this transform emits nothing.
INSERT INTO balances (account, balance)
VALUES ($recipient, $amount)
ON CONFLICT(account) DO UPDATE SET balance = balance + $amount;
-- Produce no output rows (so nothing gets published from this transform):
SELECT NULL WHERE 0;
The SELECT NULL WHERE 0 idiom is how you write a transform that updates state but emits no document. Needed because every SQL lambda must be a syntactically valid query.
Canonical Example — TypeScript
TypeScript derivations support stateful work via an in-memory state object, but the idiomatic stateful path in Estuary is SQLite. Use TypeScript for stateful work only if you have a specific reason (complex Deno-side libraries, WASM, etc.) — otherwise SQLite is more robust, survives shard splits cleanly via the recovery log, and has a clearer operational story.
Test
Local validation — flowctl preview --fixture
Stateful derivations build state within a preview run. Ingest an ordered sequence of transfers and watch the outcomes evolve. Note: flowctl preview uses an in-process runtime (no temp-data-plane subprocess), which also means shard-splitting semantics won't be exactly like production — but for single-shard stateful logic, preview is a good reflection for the primary transform.
Important caveat for self-referential derivations under --fixture: flowctl preview --fixture does NOT simulate transforms that source from the derivation's own output. The fixture file is the only input the preview reader sees, so a creditRecipient-style self-read sees no documents — the recipient side won't be credited. (Without --fixture, journal-backed preview can read historical self-output, but this requires a previously-published live collection.) For self-referential patterns under development, validate in a real data plane: ingest via HTTP or a capture and read back from the derived collection.
fixture.jsonl:
["acmeCo/production/transfers", {"transfer_id": "t1", "sender": "alice", "recipient": "bob", "amount": 50}]
["acmeCo/production/transfers", {"transfer_id": "t2", "sender": "bob", "recipient": "alice", "amount": 20}]
["acmeCo/production/transfers", {"transfer_id": "t3", "sender": "alice", "recipient": "bob", "amount": 1000}]
{"commit": true}
Starting balances are all 0. Expected outcomes:
t1— alice has 0, tries to send 50 → rejected (sender_balance_after: 0)t2— bob has 0, tries to send 20 → rejected (sender_balance_after: 0)t3— alice still has 0 → rejected
To see approvals you need to seed balances first. For a richer test, add a "deposit" transform that credits accounts without checking — see the AcmeBank tutorial for the full flow.
flowctl preview --source flow.yaml \
--name acmeCo/processed/transfer-outcomes \
--fixture fixture.jsonl --timeout 30s
Spec-defined tests — tests: block (aspirational)
tests:
acmeCo/tests/transfer-outcomes:
- ingest:
description: Three transfers with zero starting balances — all should reject.
collection: acmeCo/production/transfers
documents:
- { transfer_id: "t1", sender: "alice", recipient: "bob", amount: 50 }
- { transfer_id: "t2", sender: "bob", recipient: "alice", amount: 20 }
- { transfer_id: "t3", sender: "alice", recipient: "bob", amount: 1000 }
- verify:
collection: acmeCo/processed/transfer-outcomes
documents:
- { transfer_id: "t1", sender: "alice", outcome: "rejected", sender_balance_after: 0 }
- { transfer_id: "t2", sender: "bob", outcome: "rejected", sender_balance_after: 0 }
- { transfer_id: "t3", sender: "alice", outcome: "rejected", sender_balance_after: 0 }
Variations
- Inventory tracking:
CREATE TABLE inventory (product_id TEXT PRIMARY KEY, stock INTEGER). Lambda deducts on orders, marksbackorderedif insufficient. Separate state-only transform for replenishments — justINSERT ... ON CONFLICT DO UPDATE, no SELECT needed. - Approval workflow: migration creates an
approval_statetable with approvers JSON array. One transform initialises requests, another adds approvers. Emitapprovedwhenjson_array_length(approvers) >= required. - Deduplication:
CREATE TABLE seen (event_id TEXT PRIMARY KEY). LambdaSELECT ... WHERE NOT EXISTS (SELECT 1 FROM seen WHERE event_id = $event_id)thenINSERT OR IGNORE INTO seen. Collection key uniqueness handles simple cases; use this when source has duplicates at different key granularity. - Session tracking: state per user_id, open session on login, close on logout, emit session summary on close.
- Rate limits: count events per user in state; reject once threshold hit. Combine with
derivation-windowingfor time-bounded rate limits.
Gotchas specific to stateful-logic
shuffle: anysilently breaks state. With unkeyed shuffle, each event may land on a different shard and see empty state. Useshuffle: { key: [/entity_id] }wherever you read/write SQLite tables. This is the #1 cause of "my balances don't accumulate" bugs.- Initial activation after publish takes time. Migrations run once at first shard startup; subsequent restarts only apply new migrations. Transient errors right after publish typically resolve on their own as shards finish initializing.
- State-only transforms can use bare DML. When a transform only updates state and emits nothing, plain
INSERT,UPDATE, orDELETEstatements are valid — no trailing SELECT needed. (The canonical AcmeBankcreditRecipientlambda is justINSERT ... ON CONFLICT DO UPDATE.) - Self-referential transforms (reading from your own output) are powerful but subtle. Use
partitions.includeto filter which self-events the transform processes, or you can create infinite loops. - Migrations are append-only. You cannot change a published migration — you must add a new one. State is destroyed only on shard delete/recreate (disable/enable preserves it). For critical state, plan how you'd rebuild from source-of-truth events if a recreate is ever needed.
- Shard splits fork the recovery log. Both new shards initially hold identical SQLite state; subsequently each processes a disjoint key range under keyed shuffle. Stale rows for the other shard's range remain in each DB but are harmless because no reads target them.
- Don't use
shuffle: anywithON CONFLICT. You might thinkON CONFLICTmakes it idempotent, but without keyed shuffle, concurrent updates to the same row across shards will not converge. - Order matters within a lambda. Query state before updating it if you want to read pre-update values.
SELECT balance FROM balances ...beforeINSERT ... ON CONFLICT UPDATE.
Related
derivation-basics— prerequisite (stateless-vs-stateful, shuffle mechanics)derivation-windowing— time-bounded state viareadDelayderivation-aggregate-metrics— when reductions can express the logic (no procedural state needed)- AcmeBank derivations tutorial — the canonical walk-through for this pattern
- Internal task state