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
17 changes: 14 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
# Changelog

## 3.5.1 — review fixes: bridged fetches, to-many paths, JSON re-checks

Fail-closed and doc findings from the 3.5.0 adversarial review.
## 3.5.1 — review fixes: bridged fetches, to-many paths, JSON re-checks; clampLens

Fail-closed and doc findings from the 3.5.0 adversarial review, and one lens-layer op the
consumers each kept a copy of.

- **`clampLens(lens, clamps)`** (and `LensClamps`): the lens with clamps ANDed into
its first narrowing over the base lens (one is added over a bare lens) — a `root` where, and
per-map model-default wheres and source wheres (a source's `where` ANDs; its `label` /
`groupBy` win and a source clamp on a field with none adds it; a bare `Condition` entry reads as
its `where`, as the engine reads it). Only the
first layer's clamps read the whole schema, so a clamp on something a later layer hides goes
there. Later layers are returned as they were. Replaces the `intoFirstLayer` copies in template
(`@template/db/lens`: email registry slot clamps, `scopeEmailLens`), Kingdom and Tribe (their
ports), and Zealot (`@zealot/db`: notification delivery `resolveAudience`).

- **`toLensSelect` selects a bridged source's local reads.** It skipped every column a source across
a bridge reads, so `materializeSources` over its rows plus the far side threw `UsageError: lacks
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -841,7 +841,7 @@ Lens & bridges:
- `FieldMap`, `FieldMapEntry`, `ModelEntry`, `SourceOption`, `FieldMapSet`, `Bridge`, `BridgeEndpoint`, `BridgeCardinality`, `BridgeDictionary`
- `createLens`, `storeLens`, `composeLens`, `StoredLens`, `stitchFieldMaps`, `indexBridges`, `validateFieldMaps`, `assertValidFieldMaps`
- `validateNarrowing`, `assertValidNarrowing`, `validateRuleInLens`, `narrowRule`, `coerceRule`
- `bindLens`, `listLensBindings`, `getLensRoot`
- `bindLens`, `listLensBindings`, `getLensRoot`, `clampLens`, `LensClamps`
- `projectLens`, `walkLensPath`, `readLensValue`, `LensValue`, `describeRule`, `describeRuleSources`
- `toLensSelect`, `projectRows`, `LensSelect`, `LensRelationSelect`, `LensSelectOptions`, `ProjectRowsOptions`
- `toSourceQueries`, `materializeSources`, `materializeSourceQuery`
Expand Down Expand Up @@ -1159,6 +1159,7 @@ Composition across chained narrowings is pure intersection: relations are turned
| `validateRuleInLens(rule, lens)` | Validates a user rule's field paths and enum values against the narrowed lens, path-aware. Returns `{ ok, errors: { path, message, code }[] }` like `validateRule` (codes such as `not_in_lens`, `operator_kind_mismatch`, `invalid_value`, `value_not_allowed`). Every relation a field, a value ref, an offset or an `orderBy` / aggregate field crosses must be turned on. A bare value `path` is a root-row column, gated like a field. The security gate. |
| `describeRule(rule, lens)` | `{ sources, bridgesCrossed, supportedTargets, errors }`: the maps a rule reads, whether it crosses a bridge, which of `check` / `toPrisma` / `toSql` can run it (by the rule's shape, as `validateRule` reads it — a compiler may still refuse a field's kind, such as a date rule on a String column on Prisma), and the lens gate's `ValidationIssue`s. For routing and UX; `validateRuleInLens` stays the gate. |
| `getLensRoot(lensOrNarrowing)` | The base lens a narrowing chain is rooted at; a lens is its own. Throws on a cyclic chain. |
| `clampLens(lens, clamps)` | The lens with `clamps` (`{ root?: { where }, mapDefaults?: { [map]: { models: { [model]: { where?, sources? } } } } }`) ANDed into its first narrowing over the base lens, one added over a bare lens; later layers kept as they were. Only the first layer's clamps read the whole schema, so a clamp on what a later layer hides goes here. A source's `where` ANDs; its `label` / `groupBy` win, and a source clamp on a field with none adds that option set. Wheres only narrow. |
| `lensVisit(lens, relationPath, options?)` | One visit as `projectLens` (by path) gives it — its shown fields with values and options, sources, labels and axes — at a dotted relation path from the anchor (`''` for the anchor), resolved on demand: nothing is enumerated, so it is cheap on any schema. `null` when a relation on the path isn't shown there (off, omitted, or outside the model-default tree). A builder walks a lens with it instead of re-deriving the lens's rules. |
| `walkLensPath(lens, path)` | Resolves one dotted path through the lens hop by hop: `{ outcome: 'resolved', hops, terminal, jsonSubPath }`, or `hidden` (a column it doesn't keep, or a relation it doesn't turn on there) / `missing` / `pastScalar` with the failing `index`. |
| `readLensValue(lens, row, path, options?)` | One value off a row, as the lens shows it: `{ ok: true, value }`, or `{ ok: false, reason }` — `hidden` / `missing` / `pastScalar` (the walk `validateRuleInLens` gates a field with), `relation` (the path ends on rows, not a value) or `list` (it crosses a to-many). Each row on the way, the root included, is checked against its visit's clamps: one a clamp hides, or a missing one, reads `null`. Only own properties are read, into a Json column too. `options` is what each clamp is checked with (`now`, `bindings`). For values a template interpolates. |
Expand Down
9 changes: 9 additions & 0 deletions docs/LENS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ What one layer may do, given the layers above it (layer 1 is the first narrowing
| `sources` `where` / `label` / `groupBy` | `where` ANDs; a later `label` / `groupBy` wins | the `where` is a clamp (as above); a label or axis reads only relations shown at each visit the source is projected, and columns every other layer shows (only the layer that set the value in force is exempt from its own hiding) |
| `from: 'mapDefaults'` pointers | — | escape only their own layer's path clamps; every other layer's still apply |

`clampLens(lens, clamps)` puts clamps where they may read the whole schema: it ANDs a
`root` where, and per-map model-default wheres and source wheres, into the first narrowing over
the base lens (adding one over a bare lens) and keeps every later layer as it was.

```ts
// A delivery clamp on columns the caller's viewer layer hides
const deliverable = clampLens(lens, { root: { where: { field: 'deletedAt', operator: 'notExists' } } });
```

## 2. Two kinds of narrowing

The most important thing to internalize: a `LensNarrowing` contains two distinct
Expand Down
1 change: 1 addition & 0 deletions docs/VERBS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ modules allowed to implement it, and every exported function must appear in this
| bind | `bindRule`, `bindLens` | Substitute supplied bindings into a rule, or a lens's narrowing conditions. |
| bind | `listBindings`, `listLensBindings` | The bind names a rule or lens reads, sorted (`{ required }` drops `bindOptional`). |
| narrow | `narrowRule` | Inject a lens's clamps into a rule at their anchors. |
| clamp | `clampLens` | AND clamps into a lens's first narrowing over the base, where they read the whole schema; later layers kept. |
| coerce | `coerceRule` | Stamp each field rule with its field's `coerceType` from the lens. |
| project | `projectLens` | What a lens exposes: by path along the relations turned on (`by: 'path'`), or flattened into a Lens (`by: 'model'`). |
| project | `projectRows` | Rows cut to what a lens shows: hidden columns, relations that are off or omitted, and hidden rows removed. |
Expand Down
2 changes: 2 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,14 @@ export type {
export {
assertValidNarrowing,
bindLens,
clampLens,
coerceRule,
composeLens,
createLens,
describeRule,
describeRuleSources,
getLensRoot,
type LensClamps,
type LensPathHop,
type LensPathResolution,
type LensRelationSelect,
Expand Down
89 changes: 89 additions & 0 deletions src/lens/clampLens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { Condition } from '../types.ts';
import { isLens } from './chain.ts';
import { normalizeSource } from './policy.ts';
import type {
Lens,
LensNarrowing,
ModelDefaultNarrowing,
NarrowingDefaults,
SourceEntry,
} from './types.ts';

type Clamps = Pick<ModelDefaultNarrowing, 'where' | 'sources'>;

/** Clamps for {@link clampLens}: a `root` where, and per-map model-default wheres and
* source wheres. */
export type LensClamps = {
root?: Pick<ModelDefaultNarrowing, 'where'>;
mapDefaults?: Record<string, { models: Record<string, Clamps> }>;
};

const both = (a?: Condition, b?: Condition): Condition | undefined =>
a === undefined ? b : b === undefined ? a : { all: [a, b] };

const mergeSources = (
a: ModelDefaultNarrowing['sources'],
b: ModelDefaultNarrowing['sources'],
): ModelDefaultNarrowing['sources'] => {
if (!a || !b) return a ?? b;
const out: Record<string, SourceEntry> = { ...a };
for (const [field, entry] of Object.entries(b)) {
const prior = out[field];
if (prior === undefined) {
out[field] = entry;
continue;
}
const left = normalizeSource(prior);
const right = normalizeSource(entry);
const where = both(left.where, right.where);
out[field] = { ...left, ...right, ...(where === undefined ? {} : { where }) } as SourceEntry;
}
return out;
};

const withClamps = <N extends ModelDefaultNarrowing>(
narrowing: N | undefined,
clamps: Clamps,
): N => {
const where = both(narrowing?.where, clamps.where);
const sources = mergeSources(narrowing?.sources, clamps.sources);
return {
...narrowing,
...(where === undefined ? {} : { where }),
...(sources ? { sources } : {}),
} as N;
};

const mergeDefaults = (
defaults: NarrowingDefaults | undefined,
clamps: Record<string, Clamps>,
): NarrowingDefaults => {
const models: Record<string, ModelDefaultNarrowing> = { ...defaults?.models };
for (const [model, modelClamps] of Object.entries(clamps))
models[model] = withClamps(models[model], modelClamps);
return { ...defaults, models };
};

const clampFirstLayer = (layer: LensNarrowing, clamps: LensClamps): LensNarrowing => {
const mapDefaults: NonNullable<LensNarrowing['mapDefaults']> = { ...layer.mapDefaults };
for (const [mapName, { models }] of Object.entries(clamps.mapDefaults ?? {}))
mapDefaults[mapName] = mergeDefaults(mapDefaults[mapName], models);
return {
...layer,
...(clamps.root ? { root: withClamps(layer.root, clamps.root) } : {}),
...(clamps.mapDefaults ? { mapDefaults } : {}),
};
};

/**
* The lens with `clamps` ANDed into its first narrowing over the base lens (one is added over a
* bare lens). A clamp that reads what a later layer hides belongs there: only the first layer's
* clamps read the whole schema. A `where` ANDs with the one in place; a source's `where` ANDs
* and its `label` / `groupBy` win. Wheres only narrow; a source clamp can add an option set for
* a field, or relabel or regroup one, but never widens what rows or columns the lens shows.
*/
export const clampLens = (lens: Lens | LensNarrowing, clamps: LensClamps): LensNarrowing => {
if (isLens(lens)) return clampFirstLayer({ parent: lens }, clamps);
if (isLens(lens.parent)) return clampFirstLayer(lens, clamps);
return { ...lens, parent: clampLens(lens.parent, clamps) };
};
1 change: 1 addition & 0 deletions src/lens/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { bindLens, listLensBindings } from './bindings';
export { getLensRoot } from './chain';
export { clampLens, type LensClamps } from './clampLens';
export { coerceRule } from './coerceRule';
export { createLens } from './createLens';
export type { RuleDescription } from './describeRule';
Expand Down
132 changes: 132 additions & 0 deletions test/lens.clampLens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { describe, expect, test } from 'bun:test';
import type { FieldMapSet } from '../src/fieldMap/types';
import { clampLens } from '../src/lens/clampLens';
import { validateNarrowing } from '../src/lens/narrowing';
import { projectLens } from '../src/lens/projectLens';
import type { Lens, LensNarrowing } from '../src/lens/types';
import { Operator } from '../src/operator';
import type { Condition } from '../src/types';

const maps: FieldMapSet['maps'] = {
app: {
models: {
User: {
fields: {
id: { kind: 'scalar', type: 'String' },
name: { kind: 'scalar', type: 'String' },
deletedAt: { kind: 'scalar', type: 'DateTime' },
orgId: { kind: 'scalar', type: 'String' },
org: {
kind: 'object',
type: 'Org',
relationName: 'UserOrg',
fromFields: ['orgId'],
toFields: ['id'],
},
},
},
Org: {
fields: {
id: { kind: 'scalar', type: 'String' },
name: { kind: 'scalar', type: 'String' },
plan: { kind: 'scalar', type: 'String' },
},
},
},
},
};

const base: Lens = { maps, mapName: 'app', model: 'User' };
const live: Condition = { field: 'deletedAt', operator: Operator.notExists };
const named: Condition = { field: 'name', operator: Operator.notEmpty };
const paid: Condition = { field: 'plan', operator: Operator.equals, value: 'paid' };

const first: LensNarrowing = {
parent: base,
root: { where: named, relations: { org: {} } },
};
const viewer: LensNarrowing = { parent: first, root: { picks: ['id', 'name'] } };

describe('clampLens', () => {
test('over a bare lens, it adds the first layer', () => {
expect(clampLens(base, { root: { where: live } })).toEqual({
parent: base,
root: { where: live },
});
});

test('a root where ANDs with the first layer’s own', () => {
expect(clampLens(first, { root: { where: live } })).toEqual({
parent: base,
root: { where: { all: [named, live] }, relations: { org: {} } },
});
});

test('later layers are kept as they are; the clamp lands under them', () => {
const clamped = clampLens(viewer, { root: { where: live } });
expect(clamped.root).toBe(viewer.root);
expect((clamped.parent as LensNarrowing).root?.where).toEqual({ all: [named, live] });
expect((clamped.parent as LensNarrowing).parent).toBe(base);
});

test('a clamp on a column a later layer hides is valid only in the first layer', () => {
const reader: LensNarrowing = { parent: viewer, root: {} };
expect(validateNarrowing({ ...reader, root: { where: live } }).ok).toBe(false);
const clamped = clampLens(reader, { root: { where: live } });
expect(validateNarrowing(clamped).ok).toBe(true);
expect(projectLens(clamped).User?.whereClauses).toEqual([{ all: [named, live] }]);
expect(Object.keys(projectLens(clamped).User?.fields ?? {})).toEqual(['id', 'name', 'org']);
});

test('model-default wheres AND per map and model', () => {
const withDefaults: LensNarrowing = {
...first,
mapDefaults: { app: { models: { Org: { where: named, picks: ['id', 'name'] } } } },
};
const clamped = clampLens(withDefaults, {
mapDefaults: { app: { models: { Org: { where: paid }, User: { where: live } } } },
});
expect(clamped.mapDefaults).toEqual({
app: {
models: {
Org: { where: { all: [named, paid] }, picks: ['id', 'name'] },
User: { where: live },
},
},
});
expect(clamped.root).toBe(withDefaults.root);
});

test('source wheres AND, a bare Condition reads as its where, and label / groupBy win', () => {
const withSources: LensNarrowing = {
...first,
mapDefaults: {
app: { models: { Org: { sources: { id: named, plan: { where: paid, label: 'name' } } } } },
},
};
const clamped = clampLens(withSources, {
mapDefaults: {
app: {
models: {
Org: {
sources: {
id: { where: paid, label: 'name' },
plan: live,
name: { where: named, groupBy: 'plan' },
},
},
},
},
},
});
expect(clamped.mapDefaults?.app?.models?.Org?.sources).toEqual({
id: { where: { all: [named, paid] }, label: 'name' },
plan: { where: { all: [paid, live] }, label: 'name' },
name: { where: named, groupBy: 'plan' },
});
});

test('no clamps leaves the first layer as it was', () => {
expect(clampLens(viewer, {})).toEqual(viewer);
});
});
Loading