Write rules reference

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.

Statements

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.

Helpers

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;
2
3function total(items) = sum(map(items, i => i.price * i.quantity));
4
5require 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.

Types

  • nullnullAlso what reading a missing field gives you.
  • booleantrue, false
  • number3, 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.

Context

What every rule can read:

  • oldany

    The item's data before the write. null on create.

  • newany

    The item's data after the write, after $jsonpad-var substitution. null on delete.

  • oldItemobject | null

    The 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 | null

    The 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 | null

    The 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.
  • tokenobject

    The API token making the request.

    • token.id string The token's id.
    • token.tags array The token's tags.
  • listobject

    The 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.
  • requestobject

    About 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.
  • nowtimestamp

    The 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.

Operators

Access

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.

Equality and ordering

== 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.

Arithmetic

  • 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.

Membership

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.

Null coalescing

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.

Precedence

Highest first:

OperatorsAssociativity
.x [i] f(…)left
! - (unary)right
* / %left
+ -left
??left, short-circuit
< <= > >= in not inleft
== !=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.

Functions

There are 44 built-in functions. They're pure: none of them reads anything outside the write being checked.

Inspection and access

  • type(value)string

    The type of a value: "null", "boolean", "number", "string", "array", "object", "timestamp" or "duration".

    type(new.price) == "number"
  • isNull(value)boolean

    Whether the value is null.

    isNull(new.count)
  • isBoolean(value)boolean

    Whether the value is a boolean.

    isBoolean(new.count)
  • isNumber(value)boolean

    Whether the value is a number.

    isNumber(new.count)
  • isInteger(value)boolean

    Whether the value is a whole number.

    isInteger(new.count)
  • isString(value)boolean

    Whether the value is a string.

    isString(new.count)
  • isArray(value)boolean

    Whether the value is an array.

    isArray(new.count)
  • isObject(value)boolean

    Whether the value is an object.

    isObject(new.count)
  • get(value, pointer, default?)any

    Read 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)boolean

    Whether 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)array

    The keys of an object, sorted.

    keys(new) == ["name", "price"]
  • values(object)array

    The values of an object, in the order of its sorted keys.

    all(values(new.scores), s => s >= 0)
  • length(value)number

    The length of a string or array, or the number of keys in an object.

    length(new.moves) == length(old.moves) + 1

Comparing old and new

  • unchanged(pointer...)boolean

    Whether 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)boolean

    Whether a pointer reads differently in old and new: the opposite of unchanged(pointer).

    changed("/status")
  • onlyChanged(pointer...)boolean

    Whether 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()array

    The 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"]

Arrays and objects

  • first(array)any

    The first element of an array, or null if it's empty.

    first(new.moves).playerId == identity.id
  • last(array)any

    The last element of an array, or null if it's empty.

    last(new.moves).playerId == identity.id
  • slice(value, start, end?)array | string

    Part 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.moves
  • indexOf(value, item)number

    The index of the first element deep-equal to item (or of a substring), or -1.

    indexOf(old.participantIds, identity.id) >= 0
  • contains(value, item)boolean

    The 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)boolean

    Whether 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)boolean

    Whether an array ends with the elements of another (deep equality), or a string with another string.

    endsWith(new.email, "@example.com")
  • unique(array)array

    The array with deep-equal duplicates removed, keeping the first of each.

    length(unique(new.participantIds)) == length(new.participantIds)

Strings

  • lower(string)string

    The string in lower case.

    lower(new.email) == new.email
  • upper(string)string

    The string in upper case.

    upper(new.code) == new.code
  • trim(string)string

    The string without whitespace at either end.

    length(trim(new.name)) > 0

Numbers

  • sum(array)number | duration

    The 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.total
  • min(array)any

    The 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.reserve
  • max(array)any

    The 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.winningBid
  • abs(number)number

    The absolute value of a number.

    abs(new.balance - old.balance) <= 100
  • floor(number)number

    The number rounded down.

    floor(new.rating) == new.rating
  • ceil(number)number

    The number rounded up.

    ceil(new.pages) == new.pages
  • round(number)number

    The number rounded to the nearest whole number. Halves round up.

    round(new.price * 100) == new.price * 100

Higher-order functions

  • all(array, predicate)boolean

    Whether the lambda returns true for every element. True for an empty array.

    all(new.moves, m => m.playerId in old.participantIds)
  • any(array, predicate)boolean

    Whether the lambda returns true for at least one element.

    any(new.members, m => m.role == "owner")
  • count(array, predicate)number

    The number of elements the lambda returns true for.

    count(new.seats, s => s.taken) <= new.capacity
  • filter(array, predicate)array

    The elements the lambda returns true for.

    length(filter(new.votes, v => v.by == identity.id)) <= 1
  • map(array, transform)array

    The result of calling the lambda on each element.

    unique(map(new.players, p => p.id)) == map(new.players, p => p.id)

Times and durations

  • timestamp(value)timestamp

    A 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)duration

    A duration from a number of milliseconds. Literals like 5m are usually clearer.

    now - oldItem.updatedAt > duration(new.cooldownMs)
  • millis(value)number

    A timestamp as milliseconds since 1970, or a duration as milliseconds.

    millis(now) % 1000 == 0

Hashing

  • sha256(string)string

    The 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.commitment

lookup and exists are reserved for reading other items, which rules can't do yet.

Grammar

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.

When a rule can't be evaluated

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.