@niscorp/prism
Pure JSON data transformation DSL with compilation, caching, and zero code execution risk.
Transformations are JSON objects — no code strings, no eval, no security risks. Designed so LLMs can generate transformation configs that are safe to execute.
Install
pnpm add @niscorp/prism @niscorp/strata zod
@niscorp/strata and zod are required peers. @niscorp/cortex and
@niscorp/signal are optional — only the mapping agent at
@niscorp/prism/agent (mappingAgent) needs them.
Quick Example
import { evaluate } from '@niscorp/prism';
const result = evaluate(
{
fullName: {
$join: {
parts: [{ $ref: '$.user.firstName' }, { $ref: '$.user.lastName' }],
sep: ' ',
},
},
itemCount: { $length: { $ref: '$.items' } },
total: { $sum: { over: { $pluck: { over: { $ref: '$.items' }, key: 'price' } } } },
},
{
user: { firstName: 'Alice', lastName: 'Smith' },
items: [
{ name: 'Widget', price: 9.99 },
{ name: 'Gadget', price: 24.99 },
],
},
);
// → { fullName: 'Alice Smith', itemCount: 2, total: 34.98 }
Documentation
- DOCS.md — Full reference for every operation, with examples
- DESIGN.md — Architecture, design decisions, and trade-offs
API
// A config and a source in, the answer out. A config object is checked the first
// time it is seen and only evaluated after that.
evaluate(config, source, options?) → JsonValue
evaluateSafe(config, source, options?) → { ok: true, data } | { ok: false, error }
// options: { limits?, check?: 'always' } — 'always' checks everything on every call
// For a config that is stored: compile once, keep the IR, execute it
compile(config, options?) → Promise<CompiledIr>
execute(ir, source, limits?) → JsonValue
// Validation
validate(config) → { ok: true, data } | { ok: false, issues }
// JSON Schema for LLM consumption
getNodeJsonSchema(target?) → object
getConfigJsonSchema(target?) → object
Which call
evaluate(config, source) is the call for a config a program holds. The
first time it is handed a config object it checks it against the schema, and
refuses with the part that is wrong; the second time it prepares it as compile
would; from then on a call only evaluates. So a host that keeps its configs and
hands the same objects over on every request pays the check once. It is kept by
the object: a config changed in place after its first call is not read again —
hand over a new object.
Both arguments are unknown, so it is also what a host's transform seam takes.
nova's shell, tide's engine and strata's upgrader each run a config through a
function they are handed, (config, source) => unknown, and know nothing of
Prism:
import { evaluate } from '@niscorp/prism';
createTide({ store, transform: evaluate, effects }); // @niscorp/tide
createUpgrader(grammars, { transform: evaluate }); // @niscorp/strata
A host that adds values of its own to the source first (the app's "today", the
session's principal) wraps it: (config, source) => evaluate(config, { ...source, today }).
evaluate(config, source, { check: 'always' }) is for writing a config, for
tests and for tools. It keeps nothing: the config is checked on every call, and
so is the source, which must be plain JSON — one holding undefined, a
function, a Date or a non-finite number is refused.
compile and execute are for a config that is stored. The IR is JSON with
a fingerprint: keep it beside what it belongs to, and execute runs it without
checking anything again — in another process, on another day. It is the only
form that does not need the config object to be the same one.
Examples
@niscorp/prism/examples is the reference as data: one example for each
operator, named by the operator — a config, the source it is given, and what
comes out — then a few configs of several operators working together.
Each id is unique across the set and in kebab-case (sort-by for $sortBy),
so an address can be built on it. An example of several operators working
together has no op.
import { evaluate } from '@niscorp/prism';
import { PRISM_EXAMPLES, PRISM_EXAMPLE_GROUPS } from '@niscorp/prism/examples';
const map = PRISM_EXAMPLES.find((example) => example.op === '$map');
// → { id: 'map', group: 'arrays', title: '$map', description, op: '$map', source, config, expected }
evaluate(map.config, map.source); // → map.expected
// PRISM_EXAMPLE_GROUPS → [{ id: 'arrays', title: 'Arrays', description }, …]
// the groups the reference puts the operators in, in the same order
They are tested with the package (test/examples.test.ts): each one evaluates to
its expected, and every operator in OP_KEYS has exactly one example of its
own. So whatever shows them — a documentation page, an agent reading
node_modules — is showing what the installed version does.
Profiles
getProfileJsonSchema(ops, target?) is the config JSON Schema documenting only
the ops you name — derived from the full schema, never restated. MAPPING_OPS
is the set a mapping uses (rows into a shape), about 28% less prompt than the
whole grammar; the mapping agent is documented with it. That agent's data is
the Prism config itself, and its reasoning is one sentence on the mapping. A
profile narrows what a prompt teaches, never what Prism accepts: validation is always the full
ConfigSchema, and a config using an op outside the profile is still valid.
MAPPING_OPS leaves out the transform ops ($has, $renameKeys, $update,
$assert, $walk) and $fill, $replace, $keyBy, $entriesOf,
$fromEntries, $keys, $values, $type, $flatten, $flatMap, $match,
$drop, $dateAdd, $startsWith and $endsWith.
Transform ops and the grammar
Besides deriving values, a config can rewrite a document: $has,
$renameKeys, $update, $assert and $walk (every node of a tree, by
rules) — see DOCS.md § Transform Operations.
They are what strata's document migrations are written in.
@niscorp/prism/migrations publishes Prism's own grammar — PRISM_SEQUENCE
(nisc.prism, kind nisc.prism/config), PRISM_SCHEMAS. The evaluator a
migration runs through is evaluate, from the main entry (strata injects it).
The op set only ever grows. Configs are stored — endpoint requests, vex
mappings, migrations — and a migration cannot be migrated by the language it is
written in: an op is never removed or reshaped; an old form stays as sugar.
test/transform.test.ts keeps the record; a grammar change is a migration on
nisc.prism, gated by pnpm check:grammars.
License
Apache-2.0