Yoke.sh
SDK

Filters

Declarative vs function filters, FanoutIndex fast path, filterFromConfig requirement, and instance creation.

Actor instances subscribe to stream events via a filter. When an appended event matches, the runtime enqueues it on that instance's persisted queue. Filters are provided at instance creation (CreateInstanceOpts.filter) or derived from actor defaults — not on the definition alone.

InstanceFilter

type InstanceFilter<TEventMap extends EventMap = EventMap> =
  | DeclarativeFilter<TEventMap>
  | FunctionFilter<TEventMap>;

Declarative filters

Indexed, persisted, fast-path fan-out:

type DeclarativeFilter<TEventMap extends EventMap = EventMap> = {
  /** Exact type or prefix glob */
  type: keyof TEventMap & string | (string & {});
  /** Dot-path equality constraints on the event object */
  where?: Record<string, unknown>;
};

Examples:

// Exact type
{ type: "slack.message.received" }

// Prefix glob
{ type: "work.*" }

// Type + payload constraint
{
  type: "work.assigned",
  where: { "payload.lang": "typescript" },
}

// Nested paths
{
  type: "github.event",
  where: { "payload.repository.name": "yoke" },
}

where keys use dot paths into the serialized event (e.g. "payload.lang", "source.instanceId").

isDeclarativeFilter

import { isDeclarativeFilter } from "@yoke/sdk";

if (isDeclarativeFilter(filter)) {
  filter.type;
  filter.where;
} else {
  filter(event); // function
}

Function filters

In-memory predicate, slow-path fan-out — evaluated via Deno eval_filter RPC:

type FunctionFilter<TEventMap extends EventMap = EventMap> = (
  event: StreamEventInput<TEventMap>,
) => boolean;

// Example
(event) =>
  event.type === "work.assigned" && event.payload.lang === "typescript";

Function filters are not serializable. Only config is persisted in Postgres.

Two-tier evaluation

TierFilter typeEvaluationPersisted
FastDeclarativeFanoutIndex lookup by type / glob + where matchFull filter JSON in DB
SlowFunctionEvery candidate event (or type-narrowed with typeHint)Config only
append → FanoutIndex.candidate_ids(type)
           → exact type bucket
           → glob patterns (e.g. work.*)
           → ALL function-filter instances (slow path)
         → for each candidate: match filter → enqueue

FanoutIndex

Rust-side index rebuilt at boot and on instance changes (FanoutEngine.rebuild_index):

pub struct FanoutIndex {
    exact: HashMap<String, Vec<String>>,      // type → instance ids
    globs: Vec<(String, String)>,             // "work.*" → instance id
    function_instances: Vec<String>,          // all function-filter instances
}

candidate_ids(event_type) returns:

  1. Instances with exact declarative type match
  2. Instances with glob patterns matching the event type
  3. All function-filter instance ids (always candidates — predicate runs in Deno)

Declarative where clauses are evaluated after index lookup when enqueueing.

Why function filters hit every event type

The index cannot precompute arbitrary predicates. Function-filter instances are included in every fan-out candidate set; eval_filter short-circuits false matches.

Optimization: pass typeHint on instance create to narrow slow-path evaluation (not a hard guarantee, but reduces work in practice).

filterFromConfig requirement

Definitions that expect function filters after restart must provide filterFromConfig:

actor({
  configSchema: z.object({ lang: z.string() }),
  stateSchema: z.object({ count: z.number() }),

  filterFromConfig: (config) => (event) =>
    event.type === "work.assigned" && event.payload.lang === config.lang,

  async process(ctx, event) { /* ... */ },
})
Definition has filterFromConfigRestart behavior
YesRuntime calls filterFromConfig(persistedConfig) → eval_filter
NoFunction-filter instances exist in DB but receive no events until recreated with a matching definition

Declarative filters do not need filterFromConfig — the full filter is stored in Postgres.

Boot spec

get_actor_specs reports hasFunctionFilter: !!def.filterFromConfig per actor name.

Creating instances with filters

// Declarative — preferred for hot paths
await ctx.instances.create("coder", {
  config: { lang: "typescript", model: "claude-sonnet" },
  filter: {
    type: "work.assigned",
    where: { "payload.lang": "typescript" },
  },
});

// Function — flexible, slow path
await ctx.instances.create("coder", {
  config: { lang: "typescript", model: "claude-sonnet" },
  filter: (event) =>
    event.type === "work.assigned" &&
    event.payload.lang === "typescript",
  typeHint: "work.assigned", // optional optimization
});

If filter is omitted, the instance receives no events until updated (no default subscription).

Declarative vs function — when to use

Use declarative whenUse function when
Matching on type + static payload fieldsComplex logic, cross-field conditions
High event volumeLow volume or narrow typeHint
Filters must survive restart without code deployPrototyping (with filterFromConfig for prod)

filterFromConfig vs instance filter

filterFromConfigInstance filter
LocationActor definitionInstance create opts
PurposeRebuild function filter after restartInitial subscription
DeclarativeNot usedStored in Postgres
FunctionFactory for eval_filterInitial predicate (same factory on restart)

Typically the instance filter mirrors what filterFromConfig(config) would produce for that config.

Complete example

const events = defineEvents({
  "work.assigned": z.object({ lang: z.string(), role: z.string() }),
  "work.*": z.object({}).passthrough(), // if using glob types in map
});

export default defineRuntime()
  .events(events)
  .actors(({ events }) => {
    const { actor } = defineFor(events);

    return {
      tsCoder: actor({
        configSchema: z.object({ model: z.string() }),
        stateSchema: z.object({ jobs: z.number() }),

        filterFromConfig: (config) => (event) =>
          event.type === "work.assigned" &&
          event.payload.lang === "typescript",

        async process(ctx, event) {
          await ctx.state.update((s) => ({ ...s, jobs: s.jobs + 1 }));
        },
      }),

      workRouter: actor({
        configSchema: z.object({}),
        stateSchema: z.object({}),
        async process(ctx, event) {
          if (event.type === "work.assigned") {
            await ctx.instances.create("tsCoder", {
              config: { model: "default" },
              filter: {
                type: "work.assigned",
                where: { "payload.lang": event.payload.lang },
              },
            });
          }
        },
      }),
    };
  })
  .build();

See Actors — filterFromConfig and Runtime fan-out for kernel details.

On this page