Derivation filter transform
Skill estuary/agent-skills/skills/derivation-filter-transform
Agent skills for setting up and operating Estuary data pipelines through your AI assistant.
npx -y skills add estuary/agent-skills --skill derivation-filter-transformAssembled 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 for stateless filtering, field selection, and per-document field transformation. Use for WHERE clauses, projecting fields, computing new fields from existing ones, or data cleansing. Derivations add complexity and cost — confirm the user wants a derivation before reaching for this (renaming alone is usually a projection, not a derivation). Use when user says "filter-transform derivation", "derivation to filter by status", "transformation with WHERE clause", "derive computed fields", "cleanse data with a derivation", or "stateless derivation".
SKILL.md
12.4 KB, as published. Nobody here has run it
derivation-filter-transform
Stateless Estuary derivation that filters documents and/or transforms field values. Each source document produces zero or one output document; no state, no cross-document coordination.
Prereq: read derivation-basics first for concepts, project layout, workflow, and language choice.
When to use this over alternatives
- Filtering (
WHEREclause): drop documents that don't match criteria - Computed fields: derive new fields from existing ones (
CASE, arithmetic, string manipulation, lookups) - Data cleansing: normalise formats, trim whitespace, replace nulls
- Selecting a subset of fields when combined with the above
For a plain rename or nested-field flattening with no computation, use schema-projections — a projection doesn't require a derivation task.
Canonical Example — SQL
Cleanse a customers collection for downstream analytics: drop inactive accounts and those with obviously invalid emails, normalise the email, and compute a tier bucket from lifetime spend.
schema.yaml
The writeSchema (what the lambda must emit). Enum constraints on tier are enforced at write time; the readSchema (in flow.yaml below) layers in inference for everything else. See derivation-basics § Schema setup.
type: object
properties:
customer_id:
type: string
email_normalized:
type: string
signup_date:
type: string
format: date
tier:
type: string
enum: [bronze, silver, gold]
required: [customer_id, email_normalized, tier]
flow.yaml
collections:
acmeCo/analytics/active-customers:
writeSchema: schema.yaml
readSchema:
allOf:
- $ref: flow://write-schema
- $ref: flow://inferred-schema
key: [/customer_id]
derive:
using:
sqlite: {}
transforms:
- name: cleanseCustomers
source: acmeCo/production/customers
shuffle: any
lambda: |
SELECT
$id AS customer_id,
LOWER(TRIM($email)) AS email_normalized,
date($created_at) AS signup_date,
CASE
WHEN $lifetime_spend >= 10000 THEN 'gold'
WHEN $lifetime_spend >= 1000 THEN 'silver'
ELSE 'bronze'
END AS tier
WHERE $status = 'active'
AND $email LIKE '%@%';
How it works:
$fieldbinds to the source document's JSON pointer/field. Nested fields use$nested$field, orjson_extract($obj, '$.path')for deep paths.- The
WHEREclause drops rows that don't match — they produce zero output documents. - Any per-row SQLite expression works:
CASE,LOWER,TRIM,date(), arithmetic, string concatenation,COALESCE,CAST.
Preview and publish via the workflow in derivation-basics.
Canonical Example — TypeScript
Same task in TypeScript. Reach for TS when the logic is too tangled for SQL: nested parsing, try/catch around dirty data, lookup maps, or splitting one input into many outputs. Here, TS lets us split full_name into first_name / last_name — awkward in SQLite.
schema.yaml
type: object
properties:
customer_id:
type: string
email_normalized:
type: string
first_name:
type: string
last_name:
type: string
signup_date:
type: string
format: date
tier:
type: string
enum: [bronze, silver, gold]
required: [customer_id, email_normalized, tier]
flow.yaml
collections:
acmeCo/analytics/active-customers:
writeSchema: schema.yaml
readSchema:
allOf:
- $ref: flow://write-schema
- $ref: flow://inferred-schema
key: [/customer_id]
derive:
using:
typescript:
module: cleanse.ts
transforms:
- name: cleanseCustomers
source: acmeCo/production/customers
shuffle: any
cleanse.ts
Generated with flowctl generate --source flow.yaml, then filled in:
import { IDerivation, Document, SourceCleanseCustomers } from 'flow/acmeCo/analytics/active-customers.ts';
export class Derivation extends IDerivation {
cleanseCustomers(_read: { doc: SourceCleanseCustomers }): Document[] {
const c = _read.doc;
// Filter: return [] to drop the document
if (c.status !== 'active' || !c.email?.includes('@')) {
return [];
}
const [first, ...rest] = (c.full_name ?? '').trim().split(/\s+/);
const tier =
c.lifetime_spend >= 10000 ? 'gold' :
c.lifetime_spend >= 1000 ? 'silver' : 'bronze';
return [{
customer_id: c.id,
email_normalized: c.email.toLowerCase().trim(),
first_name: first || '',
last_name: rest.join(' '),
signup_date: c.created_at.split('T')[0],
tier,
}];
}
}
Return [] to drop, [{...}] to emit one, [{...}, {...}] to emit many.
Test
Local validation — flowctl preview --fixture (works today)
Write newline-delimited JSON fixtures of source documents and run the derivation locally. Output can be diffed against expected:
fixture.jsonl:
["acmeCo/production/customers", {"id": "c1", "status": "active", "email": "[email protected]", "lifetime_spend": 12000, "full_name": "Alice Smith", "created_at": "2024-01-15T10:00:00Z"}]
["acmeCo/production/customers", {"id": "c2", "status": "active", "email": "[email protected]", "lifetime_spend": 2500, "full_name": "Bob Jones", "created_at": "2024-02-01T10:00:00Z"}]
["acmeCo/production/customers", {"id": "c3", "status": "active", "email": "[email protected]", "lifetime_spend": 50, "full_name": "Carol Nguyen", "created_at": "2024-03-10T10:00:00Z"}]
["acmeCo/production/customers", {"id": "c4", "status": "inactive", "email": "[email protected]", "lifetime_spend": 5000, "full_name": "Dave Kim", "created_at": "2024-04-05T10:00:00Z"}]
["acmeCo/production/customers", {"id": "c5", "status": "active", "email": "no-at-sign", "lifetime_spend": 3000, "full_name": "Eve Watson", "created_at": "2024-05-20T10:00:00Z"}]
{"commit": true}
flowctl preview --source flow.yaml \
--name acmeCo/analytics/active-customers \
--fixture fixture.jsonl --timeout 30s
Expected output (c4 dropped as inactive, c5 dropped for missing @):
{"customer_id":"c1","email_normalized":"[email protected]","signup_date":"2024-01-15","tier":"gold"}
{"customer_id":"c2","email_normalized":"[email protected]","signup_date":"2024-02-01","tier":"silver"}
{"customer_id":"c3","email_normalized":"[email protected]","signup_date":"2024-03-10","tier":"bronze"}
Spec-defined tests — flowctl catalog test
You can also declare tests in-spec with tests:. This is the documented approach and is useful in CI. flowctl preview --fixture is still handy for fast local iteration without round-tripping to the control plane.
tests:
acmeCo/tests/cleanse-customers:
- ingest:
description: Five customers covering each tier, plus an inactive account and an invalid email.
collection: acmeCo/production/customers
documents:
- { id: "c1", status: "active", email: "[email protected]", lifetime_spend: 12000, full_name: "Alice Smith", created_at: "2024-01-15T10:00:00Z" }
- { id: "c2", status: "active", email: "[email protected]", lifetime_spend: 2500, full_name: "Bob Jones", created_at: "2024-02-01T10:00:00Z" }
- { id: "c3", status: "active", email: "[email protected]", lifetime_spend: 50, full_name: "Carol Nguyen", created_at: "2024-03-10T10:00:00Z" }
- { id: "c4", status: "inactive", email: "[email protected]", lifetime_spend: 5000, full_name: "Dave Kim", created_at: "2024-04-05T10:00:00Z" }
- { id: "c5", status: "active", email: "no-at-sign", lifetime_spend: 3000, full_name: "Eve Watson", created_at: "2024-05-20T10:00:00Z" }
- verify:
description: c4 dropped (inactive) and c5 dropped (no @ in email); remaining three land in their respective tiers with a normalised email.
collection: acmeCo/analytics/active-customers
documents:
- { customer_id: "c1", email_normalized: "[email protected]", signup_date: "2024-01-15", tier: "gold" }
- { customer_id: "c2", email_normalized: "[email protected]", signup_date: "2024-02-01", tier: "silver" }
- { customer_id: "c3", email_normalized: "[email protected]", signup_date: "2024-03-10", tier: "bronze" }
For the TypeScript version, add first_name and last_name to each verify document (e.g. first_name: "Alice", last_name: "Smith").
Variations
- Pass through whole document (filter-only):
SELECT JSON($flow_document) WHERE $status = 'active';— emits each matching source document verbatim at the top level, without listing fields. Estuary's equivalent ofSELECT *(which SQLite derivations don't support). - Preserve deletes through a filter: a
WHEREon a business field drops delete events, because a delete carries only the key and_meta(the tested field binds as NULL). To keep hard deletes propagating downstream, exempt deletes from the filter and double-emit them in the same lambda:
The delete is emitted twice so the reduction has a prior document to act on; seeSELECT JSON($flow_document) WHERE $_meta$op != 'd' AND $status = 'active'; SELECT $id, $_meta WHERE $_meta$op = 'd'; SELECT $id, $_meta WHERE $_meta$op = 'd';derivation-basics§ Handling deletes for the full mechanism. - Pass through + add/modify a field:
SELECT json_set($flow_document, '$.newField', <expr>);—json_set,json_insert, andjson_replaceeach handle existing/new values differently (https://sqlite.org/json1.html#jins). Needed becauseSELECT JSON($flow_document), extra_colre-nests the document. - Filter only, explicit columns:
SELECT $id, $name, $email WHERE $status = 'active'; - Extract nested values:
$nested$fieldin SQL,doc.nested?.fieldin TS. For deep paths in SQL:json_extract($obj, '$.a.b.c'). - Lookup/mapping: in TS, define a
Record<string, string>constant and look up values per document. - NULL handling:
COALESCE($field, 'default')in SQL,doc.field ?? 'default'in TS. - Proper boolean output: SQLite stores booleans as 0/1. Use
json('true')/json('false')(often inside aCASE) to emit a real JSON boolean. - Multi-output per input: both languages support it. TS returns multiple documents from the array; SQL emits one document per output row, so
UNION ALL,json_each, or recursive CTEs all work. (For array unnesting specifically, seederivation-flatten-array.)
Gotchas specific to filter/transform
WHEREis evaluated per document, not per collection. If source documents are updated in place, the filter re-runs on the update. A document that once matched may stop matching — the derivation will emit the new version (which may be empty), not delete the old. Downstream materialisations see the last emitted state.- Prefer
shuffle: anyfor stateless transforms. Everything in this skill is stateless, so any-shard routing is simplest and avoids unnecessary co-location overhead. Keyed shuffle is not an error here — just wasteful. JSON($flow_document)vs$flow_documentmatters. TheJSON()wrapper makes the whole document become the output doc at the top level. Without it, the doc ends up nested under aflow_documentproperty and schema validation fails with"Missing required property: id"— becauseidis now inside the nested object, not at the root.
Related
derivation-basics— prerequisite readingschema-projections— for plain renames without computation, no derivation neededderivation-flatten-array— when one input should produce many outputs (array unnesting)derivation-aggregate-metrics— when you need reductions (sum, min, max) across documentsderivation-join-collections— when combining data across collections