The write rules language, in full. If you haven't met it yet, start with write rules, and the recipes for the patterns most apps need.
This page is generated from the rules engine itself, so it always describes the version that's running. Language version 1.
A rule set is a list of statements, each ending in ;. Whitespace doesn't matter, and comments are // … or /* … */.
allowallow <operations> ["label"]: <expression>; — authorises a write. If an operation has any allow statements, at least one must be true, or the write is refused (403).requirerequire <operations> ["label"]: <expression> [else "message"]; — validates a write. Every require for the operation must be true, or the write is refused (400) with the message.sharedshared update, delete; — lets identities update or delete items they don't own, with the allow statements deciding who may.letlet name = <expression>; — a named value, evaluated at most once per write.functionfunction name(a, b) = <expression>; — a helper. Functions must be declared before they're used and can't call themselves.versionversion 1; — the rules language version. Optional; must be the first statement.An allow or require statement names the operations it covers: create, update, delete, or write for all three. A restore is checked as an update, and so are the partial-data endpoints.
The optional string after the operations is a label. It shows up in the trace, in the log of refused writes and in the 400 error, so it's worth writing one.
let names a value and function names an expression with parameters. Both are top level, and both must be declared before they're used — which is also why recursion can't be written at all, rather than being caught at runtime.
1let isOwner = identity != null && oldItem.identityId == identity.id;23function total(items) = sum(map(items, i => i.price * i.quantity));45require update "total": new.total == total(new.lines)6 else "total must be the sum of the lines";
A let is evaluated once per write, the first time it's read.
nullnullAlso what reading a missing field gives you.booleantrue, falsenumber3, 2.5A double, as in JSON. / is float division, and dividing by zero is an error.string"x", 'x'Escapes are \" \' \\ \n \t \r \uXXXX. There's no interpolation.array[1, 2]object{a: 1}Key order makes no difference to equality.timestampnow, timestamp(x)Millisecond precision. It never appears in item data, because JSON has no date type.duration5m, 30s, 1wA number followed by ms, s, m, h, d, w. There's no month or year, because those aren't fixed lengths.There's no truthiness. &&, ||, !, ? : and the body of a rule all need an actual boolean. Anything else is an error, and an error refuses the write. That rules out the classic bug where an empty string quietly authorises something.
What every rule can read:
oldanyThe item's data before the write. null on create.
newanyThe item's data after the write, after $jsonpad-var substitution. null on delete.
oldItemobject | nullThe item's metadata before the write. null on create.
oldItem.id string The item's id.oldItem.identityId string | null The identity that owns the item, or null.oldItem.version string The item's version.oldItem.description string | null The item's description.oldItem.tags array The item's tags.oldItem.readonly boolean Whether the item is read-only.oldItem.createdAt timestamp When the item was created.oldItem.updatedAt timestamp When the item was last written. On newItem, the same as now.newItemobject | nullThe item's metadata after the write. null on delete.
newItem.id string The item's id.newItem.identityId string | null The identity that owns the item, or null.newItem.version string The item's version.newItem.description string | null The item's description.newItem.tags array The item's tags.newItem.readonly boolean Whether the item is read-only.newItem.createdAt timestamp When the item was created.newItem.updatedAt timestamp When the item was last written. On newItem, the same as now.identityobject | nullThe identity making the request, or null if there isn't one.
identity.id string The identity's id.identity.name string The identity's name.identity.displayName string | null The identity's display name.identity.group string | null The identity group the identity belongs to.identity.tags array The identity's tags.identity.emailVerified boolean Whether the identity has verified its email address.identity.createdAt timestamp When the identity was created.tokenobjectThe API token making the request.
token.id string The token's id.token.tags array The token's tags.listobjectThe list the item is in.
list.id string The list's id.list.pathName string The list's path name.list.name string The list's name.requestobjectAbout the request.
request.action string "create", "update", "delete" or "restore". A restore is checked with the update rules.request.pointer string | null The JSON pointer, for requests to an item's /data/<pointer> endpoints.nowtimestampThe server's time for this request: the same instant as newItem.updatedAt and $jsonpad-var:now.
Context names can't be shadowed by a let, a function or a lambda parameter; the checker says so if you try.
a.b and a["b"] read an object member; a[i] reads an array element by integer index (there are no negative indexes — use last()).
Reading a missing member, an index past the end, or any member of null gives null. Navigation is null-safe all the way down, so old.a.b.c is simply null on a create. Reading a member of a number, string, boolean, timestamp or duration is an error, because it's almost always a mistake.
== and != are deep and structural, with no type coercion (1 == "1" is false), and they never raise an error. The one exception is a timestamp against a string: the string is read as strict ISO-8601, and a string that doesn't parse is simply not equal. That's what makes new.createdAt == now work on data holding "$jsonpad-var:now".
< <= > >= compare number with number, string with string, timestamp with timestamp or ISO string, and duration with duration. Anything else is an error — including null < 3, so a missing field fails closed rather than quietly passing.
number + - * / % numberA number. A result that isn't finite is an error.string + stringConcatenation.array + arrayConcatenation.timestamp + - durationA timestamp. A string is read as ISO-8601 first, so old.turnStartedAt + 5m works on data that stores times as strings.timestamp - timestampA duration.duration / durationA number, e.g. (now - old.startedAt) / 1h.duration * / numberA duration.x in array is deep-equality membership, key in object asks whether the key exists, and sub in string is a substring test. not in is the negation. Any other right-hand side is an error.
a ?? b is b when a is null. It binds tighter than the comparisons, unlike JavaScript, so new.count ?? 0 >= 3 means (new.count ?? 0) >= 3 — what people mean when they write it.
Highest first:
| Operators | Associativity |
|---|---|
.x [i] f(…) | left |
! - (unary) | right |
* / % | left |
+ - | left |
?? | left, short-circuit |
< <= > >= in not in | left |
== != | left |
&& | left, short-circuit |
|| | left, short-circuit |
? : | right |
Comparisons and equality don't chain: a < b < c is a syntax error rather than something surprising.
There are 44 built-in functions. They're pure: none of them reads anything outside the write being checked.
type(value)stringThe type of a value: "null", "boolean", "number", "string", "array", "object", "timestamp" or "duration".
type(new.price) == "number"isNull(value)booleanWhether the value is null.
isNull(new.count)isBoolean(value)booleanWhether the value is a boolean.
isBoolean(new.count)isNumber(value)booleanWhether the value is a number.
isNumber(new.count)isInteger(value)booleanWhether the value is a whole number.
isInteger(new.count)isString(value)booleanWhether the value is a string.
isString(new.count)isArray(value)booleanWhether the value is an array.
isArray(new.count)isObject(value)booleanWhether the value is an object.
isObject(new.count)get(value, pointer, default?)anyRead a value with a JSON pointer (RFC 6901). Returns the default (null unless given) if the pointer doesn't resolve. "" is the whole value.
get(new, "/address/postcode")has(value, pointer)booleanWhether a JSON pointer resolves. Unlike reading the field, this tells a missing field apart from one that is present and null.
has(new, "/deletedAt")keys(object)arrayThe keys of an object, sorted.
keys(new) == ["name", "price"]values(object)arrayThe values of an object, in the order of its sorted keys.
all(values(new.scores), s => s >= 0)length(value)numberThe length of a string or array, or the number of keys in an object.
length(new.moves) == length(old.moves) + 1unchanged(pointer...)booleanWhether every pointer reads the same (deep-equal) in old and new. A pointer missing from both counts as unchanged. On create, old is missing; on delete, new is missing.
unchanged("/ownerId", "/createdAt")changed(pointer)booleanWhether a pointer reads differently in old and new: the opposite of unchanged(pointer).
changed("/status")onlyChanged(pointer...)booleanWhether everything that differs between old and new is at one of the pointers, or inside one. Nothing changing at all also passes. On create, it means new has no fields but these.
onlyChanged("/moves", "/currentPlayerId")changedPaths()arrayThe sorted JSON pointers at which old and new differ. Objects are compared key by key and arrays of the same length element by element; anything else that differs (including an array that changed length) is reported at its own pointer.
changedPaths() == ["/status"]first(array)anyThe first element of an array, or null if it's empty.
first(new.moves).playerId == identity.idlast(array)anyThe last element of an array, or null if it's empty.
last(new.moves).playerId == identity.idslice(value, start, end?)array | stringPart of an array or string, from start up to (not including) end. Negative indexes count from the end, and indexes past either end are clamped.
slice(new.moves, 0, length(old.moves)) == old.movesindexOf(value, item)numberThe index of the first element deep-equal to item (or of a substring), or -1.
indexOf(old.participantIds, identity.id) >= 0contains(value, item)booleanThe same as "item in value": an element of an array, a key of an object, or a substring.
contains(new.tags, "featured")startsWith(value, prefix)booleanWhether an array starts with the elements of another (deep equality), or a string with another string. startsWith(new.log, old.log) is the append-only check.
startsWith(new.moves, old.moves)endsWith(value, suffix)booleanWhether an array ends with the elements of another (deep equality), or a string with another string.
endsWith(new.email, "@example.com")unique(array)arrayThe array with deep-equal duplicates removed, keeping the first of each.
length(unique(new.participantIds)) == length(new.participantIds)lower(string)stringThe string in lower case.
lower(new.email) == new.emailupper(string)stringThe string in upper case.
upper(new.code) == new.codetrim(string)stringThe string without whitespace at either end.
length(trim(new.name)) > 0sum(array)number | durationThe sum of an array of numbers (or of durations). The sum of an empty array is 0.
sum(map(new.lines, l => l.price * l.quantity)) == new.totalmin(array)anyThe smallest element of an array of numbers, strings, timestamps or durations (all the same type), or null if it's empty.
min(new.bids) >= old.reservemax(array)anyThe largest element of an array of numbers, strings, timestamps or durations (all the same type), or null if it's empty.
max(new.bids) == new.winningBidabs(number)numberThe absolute value of a number.
abs(new.balance - old.balance) <= 100floor(number)numberThe number rounded down.
floor(new.rating) == new.ratingceil(number)numberThe number rounded up.
ceil(new.pages) == new.pagesround(number)numberThe number rounded to the nearest whole number. Halves round up.
round(new.price * 100) == new.price * 100all(array, predicate)booleanWhether the lambda returns true for every element. True for an empty array.
all(new.moves, m => m.playerId in old.participantIds)any(array, predicate)booleanWhether the lambda returns true for at least one element.
any(new.members, m => m.role == "owner")count(array, predicate)numberThe number of elements the lambda returns true for.
count(new.seats, s => s.taken) <= new.capacityfilter(array, predicate)arrayThe elements the lambda returns true for.
length(filter(new.votes, v => v.by == identity.id)) <= 1map(array, transform)arrayThe result of calling the lambda on each element.
unique(map(new.players, p => p.id)) == map(new.players, p => p.id)timestamp(value)timestampA timestamp from an ISO-8601 string (with a time zone, or a date on its own for midnight UTC) or from milliseconds since 1970.
timestamp(new.endsAt) > timestamp(new.startsAt)duration(milliseconds)durationA duration from a number of milliseconds. Literals like 5m are usually clearer.
now - oldItem.updatedAt > duration(new.cooldownMs)millis(value)numberA timestamp as milliseconds since 1970, or a duration as milliseconds.
millis(now) % 1000 == 0sha256(string)stringThe SHA-256 hash of a string's UTF-8 bytes, as lowercase hex. For commit-reveal: store the hash first, check the revealed value against it later.
sha256(new.reveal.grid + ":" + new.reveal.salt) == old.commitmentlookup and exists are reserved for reading other items, which rules can't do yet.
ruleset = [ versionDecl ] { statement } ;
versionDecl = "version" NUMBER ";" ;
statement = letDecl | functionDecl | sharedDecl | allowStmt | requireStmt ;
letDecl = "let" IDENT "=" expr ";" ;
functionDecl = "function" IDENT "(" [ IDENT { "," IDENT } ] ")" "=" expr ";" ;
sharedDecl = "shared" operations ";" ;
allowStmt = "allow" operations [ STRING ] ":" expr ";" ;
requireStmt = "require" operations [ STRING ] ":" expr [ "else" STRING ] ";" ;
operations = operation { "," operation } ;
operation = "create" | "update" | "delete" | "write" ;
expr = conditional ;
conditional = logicalOr [ "?" expr ":" expr ] ;
logicalOr = logicalAnd { "||" logicalAnd } ;
logicalAnd = equality { "&&" equality } ;
equality = relational { ( "==" | "!=" ) relational } ;
relational = coalesce { ( "<" | "<=" | ">" | ">=" | "in" | "not" "in" ) coalesce } ;
coalesce = additive { "??" additive } ;
additive = multiplicative { ( "+" | "-" ) multiplicative } ;
multiplicative = unary { ( "*" | "/" | "%" ) unary } ;
unary = ( "!" | "-" ) unary | postfix ;
postfix = primary { "." IDENT | "[" expr "]" } ;
primary = literal | call | IDENT | "(" expr ")" | arrayLit | objectLit ;
call = IDENT "(" [ argument { "," argument } ] ")" ;
argument = lambda | expr ;
lambda = IDENT "=>" expr | "(" [ IDENT { "," IDENT } ] ")" "=>" expr ;
arrayLit = "[" [ expr { "," expr } [ "," ] ] "]" ;
objectLit = "{" [ key ":" expr { "," key ":" expr } [ "," ] ] "}" ;
key = IDENT | STRING ;
literal = NUMBER | DURATION | STRING | "true" | "false" | "null" ;Calls are only allowed on names, so there are no method calls and no first-class functions. Lambdas are only allowed as arguments to the higher-order built-ins.
Comparing a number to a string, reading a member of a number, running out of budget: these are evaluation errors, not false.
An error in an allow statement means that statement didn't pass, and if no other one does, the write is refused with 403. An error in a require statement fails it, with the reason. Rules fail closed: a rule set that can't be understood refuses everything rather than letting it through.
&&, || and ?? short-circuit, so identity != null && identity.id == x is safe.