Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,65 @@
# Changelog

## 0.30.0 — json-rules 3.4: relations are fields, off until the narrowing turns them on

Breaking, with no compatibility path (no users).

- **Peer `@inixiative/json-rules` ^3.4.0.** A relation is off until the source's first
narrowing turns it on (`root.relations`, `mapDefaults…models.M.relations`); a bare
source offers its anchor's columns only.
- **Gated against the narrowed lens, never the surface.** 0.29 validated, coerced and
described every rule against `projectLens(…, { by: 'model' })`, which is a bare lens —
under 3.4 every relation rule was invalid, coercion skipped relation paths and
`describe()` errored. The leaf/array `valid` flags, `coerceRule`, `describeRule` and
preset validation now read the narrowed lens.
- **Fetched values are held per path.** The surface used to carry `sourceValues` per model,
so the gate refused a value outside a fetched set on every path to that model. The gate
now knows no fetched set (json-rules takes no per-path `sourceValues`); a leaf's `valid`
folds in its `value.valid` — the set the visit offers, fetched options at their own path
cut to the values the schema allows — and preset variable options are checked against the
slot's visit the same way.
- **Each scope reads the visit the lens shows on its path.** `resolve(source)` returns a
`LensView` — `{ lens, mapName, model, visit(at) }` — each visit resolved on demand by
json-rules `lensVisit` (the gate's own rules; nothing is enumerated, so any schema is
cheap). An element scope reads the visit at its relation's dotted path, so its own
relations stay (0.29 re-anchored a bare lens there, which drops them), and a relation the
lens doesn't show on that path — off, or outside the model-default tree — is not offered. Decoration facets resolve,
seed, match and stamp through the same visits; branch fields follow only relations the
lens turns on.
- **API.** `buildRoot(condition, view, …)`; `describeScopeFields(scope, opts)` beside
`describeModelFields` (which reads a model-keyed Lens); `createView`, `viewRoot`, `viewAt`,
`visitOf`; decoration functions take a scope (`ViewAt`) where they took a Lens, and
`validateDecoration` / `useFacetFields` take the view. `useRuleBuilder` returns `lens`
(the narrowed lens) and `view`.
- **`validateDecoration`** gates presets in situ against the narrowed lens, wrapped in the
array rules that reach their scope; a `models[…]` list validates at every element scope
the lens reaches through a list relation, and one that is never an element scope (the
anchor, a to-one-only model) is reported.
- **Layered sources.** `RuleBuilderSource.narrowing` may be a list of parent-less layers,
outermost first; `composeNarrowed` chains them. `withAllRelations(source)` turns every
relation on in the first layer: every model's at the model defaults (json-rules grows them
as a tree, each model once at its nearest reach) and the anchor's own spelled at the root,
so a raw record's self-relations are on.
- **Permissions and transitions gate the raw record:** `rawView(source)` — the view of
`withAllRelations` over the record; its rel walks read every relation of each resource.
`buildActionRoot` takes `view`.
- **`lensValuePicker` / `lensScopeSurface`** walk the visits the lens shows; their start is
`{ at }` (a dotted path from the anchor) instead of `{ mapName, model }`, and a loop portal
carries its own `at` to re-anchor with. The model-repeat cut is gone: the lens decides how
deep a path goes (a spelled `owner.manager.name` is reached).
- A dotted list field no picker offers (`account.contracts` outside a branch) resolves its
relation through the visits, so its condition subtree is built rather than hidden.
- `validateDecoration` reports a `models[…]` violation once, naming every visit it holds at,
and walks only visits that can reach a `models` key's model.
- `useFilteredCollection`: json-rules 3.4's `materializeSources` throws on rows missing a key a
source or the grants on its path read — the collection in hand must carry them.
- A bare value `path` is a root-row column in 3.4 (`context` is gone); docs no longer call it
a context ref.
- Example: the Builder tab authors against the ref's source (layers included) and gates
against its narrowed lens; `vip-active` turns `orders`, `enrichments` and `crm:Account` on;
narrowing where-editors read their menu with every relation on; the decoration validator
and path picker read views.

## 0.29.0 — json-rules 3.0

- **Peer `@inixiative/json-rules` ^3.0.0.** Every call reads 3.0's one name per verb:
Expand Down
25 changes: 13 additions & 12 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,22 @@ Composes a json-rules `Condition` against a **lens**, without writing JSON.

The builder is driven by a **lens**, not a hand-written schema:

1. The server narrows a lens and produces an **exposed surface** with
`projectLens(lensOrNarrowing, { by: 'model' })` (json-rules ≥ 3.0) — a `Lens` (maps intact,
the navigable graph) containing only what the narrowing exposes. Never ship
the raw lens.
2. The builder reads that surface for field metadata and the json-rules **operator
catalog** for valid operators (target-aware), so it always matches what the
engine can actually run.
3. A finished rule is classified with `describeRule(rule, lens)` (sources touched,
bridges crossed ⇒ check-only, valid targets) and gated with
`validateRuleInLens` before execution.
1. The server narrows a lens and hands the builder a serializable **source** — maps,
anchor and parent-less narrowing layers. Relations are off until the first layer turns
them on (json-rules ≥ 3.4).
2. The builder reads it as a **view** (`resolve`): the narrowed lens, and each visit
resolved on demand (`lensVisit`) for field metadata — each scope reads the fields the
lens shows on that path — plus the json-rules **operator catalog** for valid operators
(target-aware), so it always matches what the engine can actually run.
3. Every rule is gated (`validateRuleInLens`), coerced (`coerceRule`) and classified
(`describeRule`: sources touched, bridges crossed ⇒ check-only, valid targets) against
the narrowed lens itself. A `projectLens(…, { by: 'model' })` surface is a bare lens —
it turns no relation on — so it is never the gate.

### Layers

- **`schema/surface.ts`** — `describeModelFields(lens, mapName, modelName, opts)`
→ `BuilderField[]`: per-field kind, valid operators (intersected across the
- **`schema/surface.ts`** — `resolve(source)` → `LensView` (the narrowed lens + its
visits by path); `describeScopeFields(scope, opts)` → `BuilderField[]`: per-field kind, valid operators (intersected across the
configured `targets`), enum values, and relation targets for drill-down.
`valueShapeForOperator` drives which input slot to render. ✅ Done + tested.
- **`core/tree.ts`** — pure, immutable, path-addressed `Condition` mutations:
Expand Down
46 changes: 39 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,35 @@ function Leaf({ node }: { node: LeafNode }) {
}
```

### Relations are off until the narrowing turns them on

Since json-rules 3.4 a relation is a field, off by default: a bare `source` offers its
anchor's columns and nothing else. The source's `narrowing` (its first layer) turns
relations on — along a path (`root.relations`) or wherever a model is visited
(`mapDefaults[map].models[Model].relations`). Model-default turn-ons grow a tree from the
anchor and each spelled path — each model once, at its nearest reach — so a second way to a
model is spelled under `root.relations`:

```ts
const source = {
maps, mapName: 'app', model: 'User',
narrowing: { root: { relations: { orders: { relations: { items: {} } } } } },
};
```

The builder offers exactly what the narrowed lens shows on each path — an element scope
reads the visit the lens shows at that relation — and gates, coerces and describes every
rule against the narrowed lens itself (`useRuleBuilder().lens`), never against a projected
surface. `narrowing` may also be a list of parent-less layers, outermost first, each
narrowing the one before. `withAllRelations(source)` turns every relation on in the first
layer, and spells the anchor's own at the root — the posture of a raw record or a
first-layer grant. A view resolves each visit on demand (json-rules `lensVisit`), so nothing
is enumerated; `rawView(source)` is the raw-record view permissions and transitions gate.

A sourced field's fetched options (`sourceValues`) ride the visit at their own path: the
picker offers them there and the node's `valid` holds its value to them. The lens gate does
not know fetched sets, so another path to the same model keeps its declared values.

### Rendering values

`useRuleBuilder` is headless — it does **not** render value inputs, because the
Expand Down Expand Up @@ -164,7 +193,8 @@ descriptor exposes what is reachable so a renderer never spells a prefix:
`field.set`. Absent at the root.
- `value.path.scopes` (path mode) — the value locations a `path` may name: the
current row as `$.`, then each enclosing scope. Relations and lists are not
offered; a bare context path is not enumerable.
offered. A bare path is a root-row column (json-rules 3.4) — the outermost scope,
offered under its prefix.

Validity is judged in situ: a leaf's `valid` gates the node wrapped in the array rules
it sits under, so `$$.creditLimit` is valid one array deep and `$$$.creditLimit` is not.
Expand Down Expand Up @@ -406,14 +436,16 @@ with a registry stores its own by-name reference.

## API

- `useRuleBuilder(opts)` → `{ value, root, lens, setCondition, validate, describe }`
- `useRuleBuilder(opts)` → `{ value, root, lens, view, setCondition, validate, describe }` — `lens` is the narrowed lens (the gate), `view` what the builder reads
- `useFilteredCollection({ ...opts, rows, checkOptions? })` → the same surface plus `data` (rows passing the current rule)
- `buildRoot(condition, lens, fields, maxDepth, commit)` — the pure tree builder behind the hook
- `resolve(source, { sourceValues })` — compose a `RuleBuilderSource` (+ fetched values) into the exposed surface
- `describeModelFields(lens, map, model, { labels, valueLabels, targets })` — the selectable fields + operator sets
- `buildRoot(condition, view, fields, maxDepth, commit)` — the pure tree builder behind the hook
- `resolve(source, { sourceValues })` → `LensView`: the narrowed lens (`view.lens`, the gate) and `view.visit(at)`, the fields the lens shows at a dotted path from the anchor (`'User'`, `'User.orders'`)
- `createView(lensOrNarrowing, { sourceValues })` — the same over a lens in hand; `rawView(source)` — a raw record, every relation on (permissions, transitions)
- `composeNarrowed(source)` — the source's narrowed lens; `withAllRelations(source)` — every relation turned on in its first layer
- `describeScopeFields(viewAt(viewRoot(view), path), { labels, valueLabels, targets })` — the selectable fields + operator sets at one scope; `describeModelFields(lens, map, model, …)` reads a model-keyed Lens
- `runSources(lensOrNarrowing, rows)` — DISTINCT option sets for sourced fields
- `lensValuePicker` / `useLensValuePicker` — the field/path picker atom
- `lensScopeSurface` / `useLensScopeSurface` — one scope's `{ values, loops }`: leaves flattened through to-one relations (unbounded, cycle-cut), to-many relations emitted as loop portals instead of being walked
- `lensValuePicker` / `useLensValuePicker` — the field/path picker atom (`{ at, maxDepth }`)
- `lensScopeSurface` / `useLensScopeSurface` — one scope's `{ values, loops }`: leaves flattened through the to-one relations the lens turns on (the lens decides how deep), to-many relations emitted as loop portals instead of being walked; a loop's own scope is `{ at: loop.at }`
- `parseSavedRule` / `stringifySavedRule` — validated rule serialization

See [PLAN.md](./PLAN.md) for the architecture.
Expand Down
6 changes: 3 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions examples/RuleTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,8 @@ const ValueField = ({ value }: { value: ValueControl }) => {
onChange={(m) => value.setMode(m as 'value' | 'path' | 'bind')}
/>
{value.mode === 'path' && value.path ? (
// `$.` is this row, `$$.` the enclosing element, … A bare (context) path is not
// enumerable, so a saved one is kept selectable as itself.
// `$.` is this row, `$$.` the enclosing element, … A saved bare path (a root-row
// column) or any other spelling not offered is kept selectable as itself.
<Picker
ariaLabel="path"
value={value.path.value}
Expand Down
11 changes: 9 additions & 2 deletions examples/samples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,13 +244,16 @@ export const segmentDecoration: Decoration = {
},
};

/** Two narrowings: one off the lens, one chained off that narrowing — each only restricts further. */
/** Narrowings off the lens, one chained off another — each only restricts further. A bare lens
* turns no relation on, so the first narrowing over it is what opens `orders` and the rest. */
export const sampleNarrowings: Record<string, SavedNarrowing> = {
'vip-active': {
parent: { kind: 'lens', name: 'app-users' },
narrowing: {
root: {
picks: ['id', 'email', 'tier', 'role', 'active', 'metadata'],
// Relations are off until the first narrowing turns them on (json-rules 3.4).
relations: { orders: {}, enrichments: {}, 'crm:Account': {} },
where: { all: [{ field: 'active', operator: 'equals', value: true }] },
enumPicks: { role: ['admin', 'member'] },
sources: { tier: { all: [{ field: 'active', operator: 'equals', value: true }] } },
Expand All @@ -271,6 +274,10 @@ export const sampleNarrowings: Record<string, SavedNarrowing> = {
parent: { kind: 'narrowing', name: 'vip-active' },
narrowing: { root: { enumPicks: { role: ['admin'] } } },
},
'with-orders': {
parent: { kind: 'lens', name: 'app-users' },
narrowing: { root: { relations: { orders: {} } } },
},
};

/**
Expand Down Expand Up @@ -340,7 +347,7 @@ export const sampleTransitions: Workspace['transitions'] = {
*/
export const sampleRules: Workspace['rules'] = {
'over-limit order': {
source: { kind: 'lens', name: 'app-users' },
source: { kind: 'narrowing', name: 'with-orders' },
rule: {
all: [
{
Expand Down
33 changes: 15 additions & 18 deletions examples/tabs/BuilderTab.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,30 @@
import { check, describeRule, projectLens, validateRuleInLens } from '@inixiative/json-rules';
import { check, describeRule, validateRuleInLens } from '@inixiative/json-rules';
import { useEffect, useMemo, useState } from 'react';
import { type RuleBuilderSource, runSources } from '../../src';
import { composeNarrowed, resolve, runSources } from '../../src';
import { RuleEditor } from '../RuleTree';
import { RuleEditorShadcn } from '../RuleTreeShadcn';
import { sampleRows } from '../samples';
import { Badge, Button, Code, EditorHeader, Empty, Panel, Row, Select, tokens } from '../ui';
import { type ParentRef, resolveRef } from '../workspace';
import { type ParentRef, sourceFor } from '../workspace';
import type { TabProps } from './types';

type SourceChoice = { key: string; label: string; ref: ParentRef };
const refKey = (r: ParentRef) => `${r.kind}:${r.name}`;

export const BuilderTab = ({ ws, patch, selected }: TabProps & { selected?: string }) => {
const choices = useMemo<SourceChoice[]>(
// Narrowings lead: a bare lens turns no relation on, so it offers the anchor's columns only.
() => [
...Object.keys(ws.lenses).map((n) => ({
key: `lens:${n}`,
label: `lens · ${n}`,
ref: { kind: 'lens' as const, name: n },
})),
...Object.keys(ws.narrowings).map((n) => ({
key: `narrowing:${n}`,
label: `narrowing · ${n}`,
ref: { kind: 'narrowing' as const, name: n },
})),
...Object.keys(ws.lenses).map((n) => ({
key: `lens:${n}`,
label: `lens · ${n}`,
ref: { kind: 'lens' as const, name: n },
})),
],
[ws.lenses, ws.narrowings],
);
Expand Down Expand Up @@ -55,17 +56,13 @@ export const BuilderTab = ({ ws, patch, selected }: TabProps & { selected?: stri
const surface = useMemo(() => {
if (!choice) return null;
try {
const resolved = resolveRef(ws, choice.ref);
if (!resolved) throw new Error('surface not resolvable');
const source = sourceFor(ws, choice.ref);
if (!source) throw new Error('surface not resolvable');
// engine compiles the source queries; app runs them over sample rows → fetched values
// fold into the projection so option sets reflect the lens/narrowing, not the raw column.
const sourceValues = runSources(resolved, sampleRows);
const lens = projectLens(resolved, { sourceValues, by: 'model' });
const source: RuleBuilderSource = {
maps: lens.maps,
mapName: lens.mapName,
model: lens.model,
};
// fold into the view so option sets reflect the lens/narrowing, not the raw column.
const sourceValues = runSources(composeNarrowed(source), sampleRows);
// The gate is the narrowed lens itself, never its projected surface.
const { lens } = resolve(source, { sourceValues });
return { error: null as string | null, source, sourceValues, lens };
} catch (err) {
return { error: String(err), source: null, sourceValues: [], lens: null };
Expand Down
5 changes: 2 additions & 3 deletions examples/tabs/DecorationsTab.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { projectLens } from '@inixiative/json-rules';
import { useEffect, useMemo, useState } from 'react';
import { type Decoration, type Facet, validateDecoration } from '../../src';
import { createView, type Decoration, type Facet, validateDecoration } from '../../src';
import { Badge, Button, Code, EditorHeader, Empty, Panel, Row, Select, tokens } from '../ui';
import { type ParentRef, resolveRef } from '../workspace';
import type { TabProps } from './types';
Expand Down Expand Up @@ -218,7 +217,7 @@ export const DecorationsTab = ({ ws, patch, selected }: TabProps & { selected?:
try {
const resolved = resolveRef(ws, against.ref);
if (!resolved) return ['surface not resolvable'];
return validateDecoration(projectLens(resolved, { by: 'model' }), draft);
return validateDecoration(createView(resolved), draft);
} catch (err) {
return [String(err)];
}
Expand Down
Loading
Loading