agentsclimarketplace

Derivation windowing

Skill estuary/agent-skills/skills/derivation-windowing

Agent skills for setting up and operating Estuary data pipelines through your AI assistant.

Install
npx -y skills add estuary/agent-skills --skill derivation-windowing

Assembled 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 that maintains a sliding time window of events (e.g. "transfers in the last 24h", "failed logins in the last 5 minutes") using internal SQLite state and readDelay for time-based expiration. Derivations add complexity and cost — confirm the user wants a derivation before reaching for this. Use when user says "windowing derivation", "derivation for last-24h events", "rolling-window derivation", "sliding-window transformation", "fraud-detection derivation", "rate-limit derivation", or "time-window derivation".

SKILL.md

14.1 KB, as published. Nobody here has run it

derivation-windowing

Estuary derivation that maintains a sliding time window of events per key — events enter the window on arrival and leave it after a fixed delay. Typically used for fraud detection, rate limiting, or "recent activity" signals.

Prereq: read derivation-basics first. This skill is the most complex of the set — it combines internal SQLite state (like derivation-stateful-logic) with a second transform using readDelay to age events out of the window.

Docs:

When to use this over alternatives

  • Sliding / rolling windows: "last 24h of high-value transfers", "last 5min of failed logins"
  • Time-bounded rate limits: "more than 10 requests per user in the last minute"
  • Fraud / anomaly signals: "user did X more than N times within a window"
  • Real-time dashboards showing "activity in the last hour"

Reach for other skills when:

  • Fixed tumbling windows (hourly buckets, daily totals, no overlap) → derivation-aggregate-metrics with the hour/date as key. Much simpler.
  • State that doesn't age out on a time basis → derivation-stateful-logic
  • Latest N events (not time-bounded) → aggregate with append + truncation at query time

Before you build this — cost caveat

This is the rarest derivation pattern in practice and the most expensive. Every event lives in per-shard SQLite state for the full readDelay, so a high-volume real-time stream holds a large working set in RocksDB on the reactor — the state cost scales with event volume times window length. In most cases a simpler tool fits:

  • A destination-side window function (OVER (... RANGE ...)), which most warehouses run natively and cheaply against already-materialized data. This is usually the right answer.
  • A fixed tumbling window via derivation-aggregate-metrics keyed on the hour/date, if you only need non-overlapping buckets.

Reach for a windowing derivation only when you genuinely need a continuously-maintained sliding window upstream of the materialization (e.g. a real-time fraud/rate-limit signal that has to act before data lands). If the window is just for reporting, keep it at the destination.

How it works (one paragraph)

Two transforms read the same source. The first (no readDelay) processes each event immediately — adding it to an internal SQLite window table and emitting any derived output based on the current window. The second (with readDelay: 24h or similar) processes the same events later — removing them from the window table. Net effect: each event spends exactly readDelay inside the window. The output collection reflects the state as of "the last event processed" for each key.

Canonical Example — SQL

Fraud signal: flag users who make more than N high-value transfers within a 24-hour window.

Project layout

my-derivation/
├── flow.yaml
├── schema.yaml
├── add.sql       # check window + add to window state
├── remove.sql    # remove from window after readDelay
└── init.sql      # migration: window_transfers table

init.sql (migration)

CREATE TABLE IF NOT EXISTS window_transfers (
  transfer_id TEXT PRIMARY KEY NOT NULL,
  user_id     TEXT NOT NULL,
  amount      REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user ON window_transfers (user_id);

schema.yaml

type: object
properties:
  user_id:
    type: string
  transfer_id:
    type: string
  amount:
    type: number
  timestamp:
    type: string
    format: date-time

  # Window-derived fields:
  window_count:
    description: Number of tracked transfers in the last 24h (including this one)
    type: integer
  window_total:
    description: Sum of amounts in the last 24h (including this one)
    type: number
  is_suspicious:
    type: boolean

required: [user_id, transfer_id]

flow.yaml

collections:
  acmeCo/fraud/suspicious-transfers:
    writeSchema: schema.yaml
    readSchema:
      allOf:
        - $ref: flow://write-schema
        - $ref: flow://inferred-schema
    key: [/user_id, /transfer_id]
    derive:
      using:
        sqlite:
          migrations:
            - init.sql
      transforms:
        # On arrival: count what's in window + add this event
        - name: addToWindow
          source: acmeCo/production/transfers
          shuffle: { key: [/user_id] }
          lambda: add.sql

        # 24h later: remove this event from window
        - name: removeFromWindow
          source: acmeCo/production/transfers
          shuffle: { key: [/user_id] }
          readDelay: 24h
          lambda: remove.sql

Both transforms shuffle on /user_id so the window for each user stays on one shard. Only the remove transform sets readDelay.

add.sql

Using JSON_OBJECT is important here so that is_suspicious becomes a proper JSON boolean — SQLite has no native boolean type, so (expr >= 5) returns 0 or 1. The special "single column named json* becomes the document" rule lets JSON_OBJECT(...) plus JSON('true') / JSON('false') produce real booleans in the output document.

-- 1. Snapshot the current window for this user BEFORE adding this event
WITH stats AS (
  SELECT
    COUNT(*)                   AS cnt,
    COALESCE(SUM(amount), 0.0) AS total
  FROM window_transfers
  WHERE user_id = $user_id
)
-- 2. Emit the enriched event (only for high-value transfers; skip tiny ones)
SELECT json_object(
  'user_id',       $user_id,
  'transfer_id',   $transfer_id,
  'amount',        $amount,
  'timestamp',     $timestamp,
  'window_count',  stats.cnt + 1,
  'window_total',  stats.total + $amount,
  'is_suspicious', json(CASE WHEN stats.cnt + 1 >= 5 THEN 'true' ELSE 'false' END)
) AS json_result
FROM stats
WHERE $amount > 500;

-- 3. Always add to window (including low-value — simpler; tweak per use case)
INSERT OR IGNORE INTO window_transfers (transfer_id, user_id, amount)
VALUES ($transfer_id, $user_id, $amount);

Critical: query before inserting. Otherwise the freshly-inserted row shows up in its own stats.cnt, which double-counts it.

remove.sql

DELETE FROM window_transfers WHERE transfer_id = $transfer_id;

A bare DELETE is a valid lambda — no trailing SELECT needed.

Canonical Example — TypeScript

TypeScript is rarely the right choice for windowing — the pattern is fundamentally SQLite-shaped (table + insert/delete/select). If you're thinking of TypeScript, reconsider whether a tumbling window via derivation-aggregate-metrics would do.

Test

Local validation — flowctl preview --fixture (partial)

Windowing has a fundamental problem for local validation: readDelay: 24h requires 24 hours of wall-clock time to pass for the remove transform to process the events. flowctl preview doesn't simulate the passage of time — its fixture timestamps are the document's content, not runtime clock.

What this means:

  • The "add" side can be validated: feed events, see how window_count evolves as events accumulate.
  • The "remove" side cannot easily be validated locally: events won't expire from the window during a preview run.

To fully test expiration, publish to a real data plane and wait, or run a custom harness (TypeScript test script that simulates time).

fixture.jsonl:

["acmeCo/production/transfers", {"transfer_id": "t1", "user_id": "alice", "amount": 600, "timestamp": "2024-06-01T10:00:00Z"}]
["acmeCo/production/transfers", {"transfer_id": "t2", "user_id": "alice", "amount": 700, "timestamp": "2024-06-01T10:05:00Z"}]
["acmeCo/production/transfers", {"transfer_id": "t3", "user_id": "alice", "amount": 550, "timestamp": "2024-06-01T10:10:00Z"}]
["acmeCo/production/transfers", {"transfer_id": "t4", "user_id": "alice", "amount": 900, "timestamp": "2024-06-01T10:15:00Z"}]
["acmeCo/production/transfers", {"transfer_id": "t5", "user_id": "alice", "amount": 1200, "timestamp": "2024-06-01T10:20:00Z"}]
["acmeCo/production/transfers", {"transfer_id": "t6", "user_id": "bob", "amount": 800, "timestamp": "2024-06-01T10:25:00Z"}]
{"commit": true}

Expected progression when previewing just the add transform (remove isn't delayed properly in preview — see gotchas):

{"user_id":"alice","transfer_id":"t1","window_count":1,"window_total":600,"is_suspicious":false,...}
{"user_id":"alice","transfer_id":"t2","window_count":2,"window_total":1300,"is_suspicious":false,...}
{"user_id":"alice","transfer_id":"t3","window_count":3,"window_total":1850,"is_suspicious":false,...}
{"user_id":"alice","transfer_id":"t4","window_count":4,"window_total":2750,"is_suspicious":false,...}
{"user_id":"alice","transfer_id":"t5","window_count":5,"window_total":3950,"is_suspicious":true,...}
{"user_id":"bob","transfer_id":"t6","window_count":1,"window_total":800,"is_suspicious":false,...}

If you include the remove-transform in preview, you'll see window_count=1 on every event — preview processes the remove immediately instead of waiting 24h. That's a preview limitation; the live pipeline honours readDelay correctly.

Spec-defined tests — tests: block

tests:
  acmeCo/tests/suspicious-transfers:
    - ingest:
        collection: acmeCo/production/transfers
        documents:
          - { transfer_id: "t1", user_id: "alice", amount: 600,  timestamp: "2024-06-01T10:00:00Z" }
          - { transfer_id: "t2", user_id: "alice", amount: 700,  timestamp: "2024-06-01T10:05:00Z" }
          - { transfer_id: "t3", user_id: "alice", amount: 550,  timestamp: "2024-06-01T10:10:00Z" }
          - { transfer_id: "t4", user_id: "alice", amount: 900,  timestamp: "2024-06-01T10:15:00Z" }
          - { transfer_id: "t5", user_id: "alice", amount: 1200, timestamp: "2024-06-01T10:20:00Z" }
    - verify:
        collection: acmeCo/fraud/suspicious-transfers
        documents:
          - { user_id: "alice", transfer_id: "t1", amount: 600,  timestamp: "2024-06-01T10:00:00Z", window_count: 1, window_total: 600,  is_suspicious: false }
          - { user_id: "alice", transfer_id: "t2", amount: 700,  timestamp: "2024-06-01T10:05:00Z", window_count: 2, window_total: 1300, is_suspicious: false }
          - { user_id: "alice", transfer_id: "t3", amount: 550,  timestamp: "2024-06-01T10:10:00Z", window_count: 3, window_total: 1850, is_suspicious: false }
          - { user_id: "alice", transfer_id: "t4", amount: 900,  timestamp: "2024-06-01T10:15:00Z", window_count: 4, window_total: 2750, is_suspicious: false }
          - { user_id: "alice", transfer_id: "t5", amount: 1200, timestamp: "2024-06-01T10:20:00Z", window_count: 5, window_total: 3950, is_suspicious: true  }

Variations

  • Different window sizes: readDelay: 1h / 24h / 168h (7d) / 30d. The duration uses humantime format — Ns, Nm, Nh, Nd, Nw all work; seconds are convenient for testing.
  • Multiple windows on the same stream: one derivation per window size. Each has its own state table and remove-transform.
  • Windowed aggregations only (no per-event output): have only a single transform that updates a user_stats table and emits the user's current metrics. No readDelay removal needed if the stats are cumulative.
  • Conditional tracking: only insert into the window if the event matches (e.g. high-value). Matching filter and tracking filter must agree.
  • Alert-only output: WHERE stats.cnt + 1 >= 5 so only suspicious events are emitted (the derived collection holds only alerts, not every event).

Gotchas specific to windowing

  • SELECT before INSERT, always. Query the window state first; then add the current event. Inserting first causes the current event to count itself, inflating window_count by 1.
  • readDelay uses humantime duration format (24h, 168h, 30m, 45s, 7d, 1w), not ISO 8601. No quoting needed.
  • Initial activation after publish takes time because migrations and indexes are created. Subsequent restarts skip migrations that already ran.
  • Window state is unbounded if the remove transform lags or fails. Watch shard lag. If stuck, events accumulate indefinitely, eventually affecting memory and query performance.
  • Use INSERT OR IGNORE when duplicates are possible (e.g. replaying events). Without it, duplicates in the source fail with UNIQUE constraint.
  • Preview can't simulate readDelay expiration. Local testing validates only the "event enters window" side. Full validation requires a live data plane.
  • Remove transform must use the same shuffle key as the add transform. Otherwise the delete runs on a shard that has no record of the event.
  • Count doesn't include the current event until you add it. The stats.cnt + 1 in the add lambda compensates: you're about to insert, so count what's in window + 1.
  • readDelay does not re-order events. It delays the processing of each event by the specified duration relative to its publish time. For strict "time = now − 24h" semantics you'd need the source to carry event-time timestamps and the lambda to filter by them explicitly.

Related

  • derivation-basics — prerequisite, especially the stateful section
  • derivation-stateful-logic — same SQLite state mechanics, but without time-based expiration
  • derivation-aggregate-metrics — simpler tumbling-window alternative when fixed buckets suffice
  • AcmeBank grouped windows of transfers
  • Internal task state

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.