From 1d0954920167b96eb613ab75ebe5ae11d8bdea41 Mon Sep 17 00:00:00 2001 From: agreenspan Date: Thu, 8 Oct 2026 15:47:27 -0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(lens):=20clampLens=20=E2=80=94=20clamp?= =?UTF-8?q?s=20into=20a=20lens's=20first=20layer=20(3.5.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lens-layer op template, Kingdom, Tribe and Zealot each kept a copy of (intoFirstLayer): AND a root where, and per-map model-default and source wheres, into the first narrowing over the base lens, where clamps read the whole schema; later layers kept. Source entries normalize through the engine's own normalizeSource. Folded into the unreleased 3.5.1. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 16 ++++- README.md | 3 +- docs/LENS.md | 9 +++ docs/VERBS.md | 1 + index.ts | 2 + src/lens/clampLens.ts | 88 ++++++++++++++++++++++++ src/lens/index.ts | 1 + test/lens.clampLens.test.ts | 132 ++++++++++++++++++++++++++++++++++++ 8 files changed, 248 insertions(+), 4 deletions(-) create mode 100644 src/lens/clampLens.ts create mode 100644 test/lens.clampLens.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 42c96ca..c49db90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,18 @@ # 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; 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 diff --git a/README.md b/README.md index 1cf120a..9da3d70 100644 --- a/README.md +++ b/README.md @@ -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` @@ -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. | | `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. | diff --git a/docs/LENS.md b/docs/LENS.md index be173b2..256db82 100644 --- a/docs/LENS.md +++ b/docs/LENS.md @@ -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 diff --git a/docs/VERBS.md b/docs/VERBS.md index 2b48fac..ac6b4cb 100644 --- a/docs/VERBS.md +++ b/docs/VERBS.md @@ -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. | diff --git a/index.ts b/index.ts index 4e29f56..59b22cd 100644 --- a/index.ts +++ b/index.ts @@ -37,12 +37,14 @@ export type { export { assertValidNarrowing, bindLens, + clampLens, coerceRule, composeLens, createLens, describeRule, describeRuleSources, getLensRoot, + type LensClamps, type LensPathHop, type LensPathResolution, type LensRelationSelect, diff --git a/src/lens/clampLens.ts b/src/lens/clampLens.ts new file mode 100644 index 0000000..76e29b7 --- /dev/null +++ b/src/lens/clampLens.ts @@ -0,0 +1,88 @@ +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; + +/** Clamps for {@link clampLens}: a `root` where, and per-map model-default wheres and + * source wheres. */ +export type LensClamps = { + root?: Pick; + mapDefaults?: Record }>; +}; + +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 = { ...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 = ( + 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, +): NarrowingDefaults => { + const models: Record = { ...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 = { ...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. Only clamps merge, so the lens gets no wider. + */ +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) }; +}; diff --git a/src/lens/index.ts b/src/lens/index.ts index 386af16..b4c8f57 100644 --- a/src/lens/index.ts +++ b/src/lens/index.ts @@ -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'; diff --git a/test/lens.clampLens.test.ts b/test/lens.clampLens.test.ts new file mode 100644 index 0000000..9c81929 --- /dev/null +++ b/test/lens.clampLens.test.ts @@ -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); + }); +}); From 4821edf521e67a10e35f1213ed76ecdc9c9d9e11 Mon Sep 17 00:00:00 2001 From: agreenspan Date: Thu, 8 Oct 2026 16:00:03 -0300 Subject: [PATCH 2/2] docs(clampLens): say what a source clamp can add Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 ++- README.md | 2 +- src/lens/clampLens.ts | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c49db90..282c67d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ 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; a bare `Condition` entry reads as its `where`, as the engine reads it). Only the + `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 diff --git a/README.md b/README.md index 9da3d70..f78dfc9 100644 --- a/README.md +++ b/README.md @@ -1159,7 +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. | +| `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. | diff --git a/src/lens/clampLens.ts b/src/lens/clampLens.ts index 76e29b7..97507c2 100644 --- a/src/lens/clampLens.ts +++ b/src/lens/clampLens.ts @@ -79,7 +79,8 @@ const clampFirstLayer = (layer: LensNarrowing, clamps: LensClamps): LensNarrowin * 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. Only clamps merge, so the lens gets no wider. + * 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);