Lists and nested fields

Struct fields become dotted paths; array fields are queried with "some / all / none / at least N" groups.

Struct fields

f.struct groups fields under one name. defineFields flattens them into dotted paths, which is what rules, exporters and compile() use:

const fields = defineFields({
  name: f.text(),
  address: f.struct({ city: f.text(), zip: f.text({ column: 'postal_code' }) }, { label: 'Address' }),
});

Object.keys(fields); // ['name', 'address.city', 'address.zip']

Field pickers group them under the struct's label (each field gets group: 'Address'). compile() reads row.address.city, MongoDB and JsonLogic use the dotted path, and SQL uses the column when there is one.

Array fields

An f.array field holds a list of records, like a user's orders. You don't compare the list directly; you put an array group on it, whose rules are about one element at a time:

const fields = defineFields({
  name: f.text(),
  orders: f.array({ product: f.text(), qty: f.number(), status: f.enum(['paid', 'refunded']) }),
});

const query: QueryOf<typeof fields> = {
  kind: 'group',
  combinator: 'and',
  children: [
    {
      kind: 'group',
      combinator: 'and',
      of: { field: 'orders', match: { count: 'gte', value: 3 } },
      children: [{ kind: 'rule', field: 'status', operator: 'eq', value: 'paid' }],
    },
  ],
};
// "at least 3 orders where status = paid"

match is one of:

Inside the group, rules use the element's fields (status, not orders.status), and the types follow: ArrayGroupOf types the children against the element schema. In the store, store.fieldsFor(id) returns the scope a node sees, and store.addArrayGroup(groupId, 'orders', 'some') adds one. The default builders show "[some ▾] of Orders where" with a "+ List condition" button.

Array fields offer isEmpty/isNotEmpty as plain rules too.

Exporting array groups