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
| Tier | Filter type | Evaluation | Persisted |
|---|---|---|---|
| Fast | Declarative | FanoutIndex lookup by type / glob + where match | Full filter JSON in DB |
| Slow | Function | Every 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 → enqueueFanoutIndex
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:
- Instances with exact declarative
typematch - Instances with glob patterns matching the event type
- 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 filterFromConfig | Restart behavior |
|---|---|
| Yes | Runtime calls filterFromConfig(persistedConfig) → eval_filter |
| No | Function-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 when | Use function when |
|---|---|
| Matching on type + static payload fields | Complex logic, cross-field conditions |
| High event volume | Low volume or narrow typeHint |
| Filters must survive restart without code deploy | Prototyping (with filterFromConfig for prod) |
filterFromConfig vs instance filter
filterFromConfig | Instance filter | |
|---|---|---|
| Location | Actor definition | Instance create opts |
| Purpose | Rebuild function filter after restart | Initial subscription |
| Declarative | Not used | Stored in Postgres |
| Function | Factory for eval_filter | Initial 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.