sdxc

Type to search, or start from one of these:

[ Data & background work ]

Rules your users write

Let customers write conditions as text or JSON, store them as data, refuse a broken one with its path, and evaluate it in a request or a job.

Last updated 2026-10-05

Sooner or later a customer asks for a rule your code does not have: notify me only when a production check fails in eu-west, only when the response is slower than two seconds, never for the staging monitors. Each one is a branch you could ship, but the next customer wants a different one. The way out is to let them write the condition themselves, store it as data, and have your app answer whether it holds for a given event.

That condition is untrusted input that lives in your database and runs on every event, so it needs a schema to accept it, a compile step to catch what a schema cannot, evaluation that never coerces a string into a number, and a way for a person to type it without writing JSON. This guide builds alert rules for a monitoring product with @sdxc/expression, which supplies all four, and accepts the rules through @sdxc/validate, with failures as @sdxc/result values.

npm add @sdxc/expression @sdxc/result @sdxc/validate @sdxc/http remix

Define the dialect once

A language is the set of operators a condition may use and how a shared condition is referenced. Define it in one module, and every step reads the same grammar: the schema that accepts a rule, the compiler, the evaluator, and the text form.

app/services/alert-conditions.ts
import { createLanguage } from "@sdxc/expression";

const ALERT_BUILTINS = [
	"all",
	"any",
	"not",
	"always",
	"eq",
	"ne",
	"in",
	"notIn",
	"lt",
	"lte",
	"gt",
	"gte",
	"startsWith",
	"endsWith",
	"contains",
	"exists",
] as const;

export const alertConditions = createLanguage({
	reference: "filter",
	builtins: ALERT_BUILTINS,
});

export type AlertCondition = typeof alertConditions.Expression;

Every built-in is there except matches. A regular expression written by a customer can take time exponential in its input, and leaving it out keeps evaluation linear in the size of the context, whatever anyone writes. A rule naming matches is refused with "matches" is not an operator of this language.

reference: "filter" lets a rule name a condition the team saved once, as { op: "filter", name: "production" }. createLanguage builds a few maps and a schema that resolves on first use, so the module is safe to load in a Worker's global scope. AlertCondition is the JSON form as a type, a union an editor completes.

Accept a rule from an API

An API client sends the rule as JSON, which is also the form you store. The language's schema is a Standard Schema, so it sits inside the body schema and validate checks the condition's shape with everything else:

app/http/controllers/api/alert-rules.ts
import { created, unprocessableEntity } from "@sdxc/http/response/json";
import { isFailure } from "@sdxc/result";
import { validate } from "@sdxc/validate";
import * as s from "remix/data-schema";
import { minLength } from "remix/data-schema/checks";
import { createAction } from "remix/router";

import { AlertRules } from "~/app/repositories/alert-rules";
import { Filters } from "~/app/repositories/filters";
import { alertConditions } from "~/app/services/alert-conditions";
import routes from "~/routes/web";

const NEW_RULE = s.object({
	name: s.string().pipe(minLength(1)),
	when: alertConditions.schema,
});

export default createAction(routes.api.alertRules.create, async (ctx) => {
	let body = await validate(ctx.request, NEW_RULE);
	if (isFailure(body)) return unprocessableEntity({ issues: body.error.issues });

	let filters = await Filters.forTeam(ctx.db, ctx.team.id);
	let compiled = alertConditions.compile(body.data.when, { references: filters });
	if (isFailure(compiled)) {
		let { message, path } = compiled.error;
		return unprocessableEntity({ error: message, path });
	}

	let rule = await AlertRules.save(ctx.db, { teamId: ctx.team.id, ...body.data });
	return created({ rule });
});

ctx.team is the team your API-key middleware published, and AlertRules and Filters are your own repositories over ctx.db.

The schema accepts anything well-formed, and compile catches the rest before the row is written: a filter the team never saved, two filters that reference each other in a cycle, an operator whose own compile step refuses its arguments. Its failure is an ExpressionError whose path names the node at fault inside the condition, such as of.1 for the second member of an all, empty for the condition itself, so a client can point at it.

Store the JSON you validated, not what compile returned. The compiled tree carries resolved filters, so storing it would freeze a copy of each filter into the rule.

Let people type the rule as text

Nobody wants to write JSON in a settings page. The text form reads the way the rule is said out loud, and parse turns it into the same JSON the API accepts:

check.environment == "production" and status != "up"
	and (region in ["eu-west", "eu-central"] or responseMs > 2000)

stringify goes the other way, so the edit page shows the stored rule as text, and the action parses what came back:

app/http/controllers/alert-rules-edit.tsx
import { redirect } from "@sdxc/http/response";
import { isFailure } from "@sdxc/result";
import { validate } from "@sdxc/validate";
import * as s from "remix/data-schema";
import * as f from "remix/data-schema/form-data";
import { createController } from "remix/router";

import { AlertRules } from "~/app/repositories/alert-rules";
import { Filters } from "~/app/repositories/filters";
import { alertConditions } from "~/app/services/alert-conditions";
import { RuleEditor } from "~/resources/views/rule-editor";
import routes from "~/routes/web";

const PARAMS = s.object({ id: s.string() });
const RULE_FORM = f.object({ when: f.field(s.string()) });

export default createController(routes.alertRules.edit, {
	actions: {
		index: async (ctx) => {
			let { id } = s.parse(PARAMS, ctx.params);
			let rule = await AlertRules.find(ctx.db, ctx.team.id, id);
			let text = rule ? alertConditions.stringify(rule.when) : "";
			return ctx.render(<RuleEditor text={text} />);
		},

		action: async (ctx) => {
			let { id } = s.parse(PARAMS, ctx.params);
			let form = await validate(ctx.formData, RULE_FORM);
			let text = isFailure(form) ? "" : form.data.when;

			let parsed = alertConditions.parse(text);
			if (isFailure(parsed)) {
				let page = <RuleEditor text={text} error={parsed.error} />;
				return ctx.render(page, { status: 400 });
			}

			let filters = await Filters.forTeam(ctx.db, ctx.team.id);
			let compiled = alertConditions.compile(parsed.data, {
				references: filters,
			});
			if (isFailure(compiled)) {
				let page = <RuleEditor text={text} error={compiled.error} />;
				return ctx.render(page, { status: 400 });
			}

			await AlertRules.update(ctx.db, ctx.team.id, id, { when: parsed.data });
			return redirect(routes.alertRules.index.href(), {
				status: redirect.Status.SeeOther,
			});
		},
	},
});

RuleEditor is your own view: a <textarea name="when"> holding text, and the error's message beside it. A parse failure also carries a 1-based line and column, so the view can point at the character that broke. A value of the wrong type fails there too: responseMs > "2000" is refused at the column of "2000", since every comparison stays within one type.

The text is a view of the rule, never the stored copy. stringify prints a canonical spelling that parse reads back to the same JSON, so a rule saved through the API and one typed into the form read the same the next time anyone opens them.

Share named filters

Twenty rules that all mean "production checks" should say so once. A filter is a condition the team saves under a name, and a rule references it as filter("production") in text or { op: "filter", name: "production" } in JSON. The references object is just those conditions by name:

app/services/filters.ts
import type { AlertCondition } from "~/app/services/alert-conditions";

import { alertConditions } from "~/app/services/alert-conditions";

export function checkFilter(
	filters: Readonly<Record<string, AlertCondition>>,
	name: string,
	when: unknown,
) {
	let next = { ...filters, [name]: when };
	return alertConditions.compile({ op: "filter", name }, { references: next });
}

Saving a filter compiles a reference to it against the set as it would be after the edit, so production naming eu-only naming production is refused with Filter "production" takes part in a reference cycle before it reaches the database. Deleting a filter is the same check in reverse: compile the team's rules against the set without it.

Each reference resolves once per references object. The language keys its compiled filters on that object, so passing the same one to every rule's compile compiles a filter shared by twenty rules exactly once, and the cache goes with the object. Treat it as fixed once you have compiled against it, and build a new one for an edit, as checkFilter does.

Evaluate in a job

A check result arrives, and every rule of its team decides whether it notifies. Evaluation is synchronous and pure, so the work belongs wherever the result is handled. Here it is a job, since sending the notification is I/O nobody waits on:

app/jobs/alerts/route.ts
import { createJobHandler } from "@sdxc/jobs";
import { isFailure } from "@sdxc/result";

import jobs from "~/app/jobs";
import { AlertRules } from "~/app/repositories/alert-rules";
import { CheckResults } from "~/app/repositories/check-results";
import { Filters } from "~/app/repositories/filters";
import { alertConditions } from "~/app/services/alert-conditions";
import { alertContext } from "~/app/services/alert-context";
import { notify } from "~/app/services/notify";

export default createJobHandler(jobs.alerts.route, async (ctx) => {
	let result = await CheckResults.find(ctx.database, ctx.input.resultId);
	if (result === null) return ctx.exit("The result was deleted");

	let rules = await AlertRules.forTeam(ctx.database, result.teamId);
	let filters = await Filters.forTeam(ctx.database, result.teamId);
	let context = alertContext(result);
	let notified = 0;

	for (let rule of rules) {
		let when = alertConditions.compile(rule.when, { references: filters });
		if (isFailure(when)) {
			ctx.log.note("alert.rule_broken", {
				rule: rule.id,
				path: when.error.path,
			});
			continue;
		}
		if (!alertConditions.evaluate(when.data, context)) continue;
		await notify(ctx, rule, result);
		notified += 1;
	}

	ctx.log.set({ alert: { notified } });
});

A rule can stop compiling after it was stored, when a filter it names changes, so the job skips it with a breadcrumb on its log and the other rules still run. When the same rules serve many events, compile them once when they load and keep the trees: evaluate is the only part that has to run per event.

The context is the vocabulary your customers write against, so build it on purpose:

app/services/alert-context.ts
import type { Context } from "@sdxc/expression";

import type { CheckResult } from "~/app/repositories/check-results";

export function alertContext(result: CheckResult): Context {
	return {
		status: result.status,
		region: result.region,
		responseMs: result.responseMs,
		check: { name: result.check.name, environment: result.check.environment },
		certificate: { expiresAt: result.certificateExpiresAt },
	};
}

Listing the fields keeps a rule from reading a column you never meant to expose, and a Date stays a value rather than an object to walk into. A path that resolves to nothing makes every operator false except exists, so a misspelled field never matches everyone. When code beside a rule reads a field the author named, such as a groupBy path deciding which alerts collapse into one, read(context, rule.groupBy) from @sdxc/expression resolves it exactly the way a condition would.

Add an operator of your own

The built-ins compare values. A rule like "the certificate expires within 14 days" needs an operator that knows about dates, and defineOperator adds it:

app/services/alert-conditions.ts
import { createLanguage, defineOperator } from "@sdxc/expression";
import { failure, success } from "@sdxc/result";
import * as s from "remix/data-schema";

const DAY_MS = 86_400_000;

const EXPIRES_WITHIN = defineOperator({
	op: "expiresWithin",
	args: ["field", "days"],
	schema: s.object({ days: s.number() }),
	compile: (node) =>
		node.days > 0 && node.days <= 365
			? success(node.days * DAY_MS)
			: failure(new Error("Expected between 1 and 365 days")),
	test: (value, _node, windowMs) =>
		value instanceof Date && value.getTime() - Date.now() <= windowMs,
});

export const alertConditions = createLanguage({
	reference: "filter",
	builtins: ALERT_BUILTINS,
	operators: [EXPIRES_WITHIN],
});

ALERT_BUILTINS is the list from the first section, unchanged.

The text form calls it as expiresWithin(certificate.expiresAt, 14), filling the node's fields in the order of args. compile runs once per node: its failure refuses the rule at the operator's path when it is saved, and what it returns arrives as test's third argument on every evaluation. The language reads the field before test runs and answers false when it is missing, so test only ever sees a value that is there, and still checks that it is a Date, because a null is a value.

Speak the same language in flags and logs

Two other packages read conditions in this language, so a rule learned once works in each. @sdxc/flags-engine exports flagConditions, its targeting dialect: every built-in, a semver operator, and references spelled segment. Its parse and stringify give a flag admin page the same text editing as above, and Feature flags covers the rules themselves.

@sdxc/logger takes a condition in the JSON form as its sampling keep, so the exemption can come from configuration rather than code:

bootstrap/logger.ts
import { createLogger } from "@sdxc/logger";

export const logger = createLogger({
	service: "alerts",
	sample: { rate: 0.05, keep: { op: "gt", field: "alert.notified", value: 0 } },
});

Every run that sent a notification is kept and the rest are sampled. The logger's language keeps every built-in and adds nothing, and reads alert.notified from what ctx.log.set wrote by its dotted path. A condition that does not compile keeps every log, so a broken exemption costs volume, never the logs it was written to keep. Sampling itself is covered in Logs, traces and timings.

Test the dialect

The language is plain functions with no I/O, so a test compiles and evaluates directly:

app/services/alert-conditions.test.ts
import { isFailure, unwrap } from "@sdxc/result";
import { expect, test } from "vitest";

import { alertConditions } from "~/app/services/alert-conditions";

test("a rule over a saved filter holds for a matching result", () => {
	let filters = {
		production: { op: "eq", field: "check.environment", value: "production" },
	};
	let parsed = unwrap(
		alertConditions.parse(`filter("production") and status != "up"`),
	);
	let when = unwrap(alertConditions.compile(parsed, { references: filters }));

	let context = { status: "down", check: { environment: "production" } };
	expect(alertConditions.evaluate(when, context)).toBe(true);
	expect(alertConditions.evaluate(when, { status: "down" })).toBe(false);
});

test("a filter nobody saved is refused at its node", () => {
	let compiled = alertConditions.compile({
		op: "all",
		of: [{ op: "always" }, { op: "filter", name: "staging" }],
	});

	expect(isFailure(compiled) && compiled.error.path).toBe("of.1");
});

The last assertion of the first test is the missing-field rule at work: a result with no check matches no rule about one. Keep the cases your customers rely on beside these, such as a cycle or a rule naming matches, so a change to the dialect fails here first.

Where to go next

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.