Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

README.md

dyfields (JavaScript)

The browser-side half of dyfields: the same declarative form schema the Go server compiles, compiled here, so a form can tell the user what is wrong before anything is submitted — with the same rules, the same error codes, and the same messages the server will produce.

Zero dependencies, ES modules, no Node built-ins in src/: it runs in a browser as-is, and under Node 18+ for tests and SSR.

npm install @brobridge/dyfields

This page is the reference. USAGE.md is the walkthrough: one form followed from the first render to the second save — what to draw, what to recompute on a keystroke, what to POST, what a reopened form is holding, and what a save actually sends. Read that one first if you are wiring this into a UI.

And two working front ends are in examples/ — the same form drawn by React and by Vue from one schema, with no per-field code and the outgoing payload shown as you type:

git clone https://github.com/BrobridgeOrg/dyfields
cd dyfields/examples/react && npm install && npm run dev   # or examples/vue

Both are TypeScript and both consume this package the way you would — through @brobridge/dyfields and its shipped declarations — so what they demonstrate is the published surface, not a private one.

Quick start

import { load } from '@brobridge/dyfields'

const compiled = load(await (await fetch('/api/forms/pipeline')).json())

// What should the renderer draw right now?
const { groups, fields } = compiled.visible(doc)

// Is what the user typed acceptable?
const { values, secrets, errors } = compiled.validate(doc)
if (errors.length === 0) await submit({ values, secrets })

load() throws a SchemaErrorList if the schema itself does not make sense — that is a bug in the schema, not in the user's input, so it fails loudly at start-up rather than silently skipping a rule. Use validateSchema(input) when you want the list of problems instead of an exception.

A document

const doc = {
  values: {
    source_type: 'postgres',
    sinks: [{ $id: 's_k1', type: 'kafka', topic: 'events' }],
  },
  secrets: {
    'source_password': 'pg-secret',
    'sinks.s_k1.auth_token': 'bearer-xyz',
  },
}

secrets is always flat and keyed by $id path, never nested inside values. That is what makes "no secret ever sits in the value tree" a property you can check at a glance instead of a convention you have to trust.

Because a secret key is built from $id, an entry that holds a secret anywhere below it must carry a client-generated $idcompiled.requiresItemID(path) tells you which lists those are, so the form can mint one when it adds a row. Charset: [A-Za-z0-9_-]{1,64}.

API

Call What it is for
load(input) parse + compile in one step
parseSchema(input) / marshalSchema(s) wire format in and out; unknown keys are rejected
compile(s) / tryCompile(s) check a parsed schema, with or without throwing
validateSchema(input) every problem in a schema, never throws
c.validate(doc) { values, secrets, errors } — the cleaned document
c.visible(doc) { groups, fields } — booleans keyed by path
c.apply(current, patch) merge a patch, then validate
c.impactOf(current, patch) the paths a patch would clear, before they are gone
c.redact(doc) { values, secretsSet }, safe to log or hand out
hasCode(errors, code) did any error carry this code

validate() returns a new document: invisible fields are removed, defaults for visible fields are filled in, and transient fields are dropped after checking. A document that already conforms comes back unchanged.

Patching

const out = compiled.apply(current, {
  values: { sinks: [{ $id: 's_h1', $deleted: true }] },
  secrets: { 'source_password': '' },   // '' means "untouched"
})

An absent key keeps its current value; $deleted removes an entry along with every secret beneath it. For secrets specifically: absent keeps, '' keeps (an untouched password input submits exactly that), null clears, any other string sets.

readonly is enforced only in apply(), because rejecting a change needs a baseline to compare against. validate() alone cannot see it. That is a documented boundary — the server must be the one running apply().

The other three condition slots are refused outright: visible_when, required_when and readonly_when may not read a transient field, because they decide the shape of the document and the server would answer them without the value. compile() reports that as transient_ref.

apply() also reports nothing about a transient field, or about a rule that reads one. A transient value is dropped from the payload by design, so it is never in a patch and never in the stored document: a server cannot check what it was never sent. Those rules stay the form's to run, and compile() works out which ones they are once, so no backend has to remember.

Errors

{ path: 'companions[1].guardian', field: 'guardian', group: 'party',
  itemPath: 'companions.c_3b71', code: 'required',
  reason: 'Guardian is required' }

path uses the index, because that is what the table row is; itemPath uses the $id, because that is what the secret key is built from. Every problem is reported in one pass — a checker that returns only the first one makes the user fix and resubmit as many times as they made mistakes.

Note the one naming difference from the wire format: results use camelCase (itemPath, secretsSet), because they are JavaScript objects that never travel back to the server. Schemas, documents and patches stay snake_case, byte-for-byte what Go reads.

Masking on the way out

A field may declare mask, which changes nothing about what is validated or stored and only says how redact() writes it:

const pub = compiled.redact(doc)
// { values: { national_id: '******6789', note: '***' }, secretsSet: [...] }

A mask is 'all', 'omit' (which removes the key entirely), or an object saying how much survives: { keep_head, keep_tail, char, width }. All of it behaves exactly as it does in Go, against the same fixtures — a mask that masked differently in the browser than on the server would put the log and the audit record in disagreement about what the user typed. See the root README for the rules.

Send only what changed

diff() is the other half, and the one a form should actually reach for:

const loaded = await (await fetch('/api/customer/42')).json()  // a redacted document
// ... the user edits one field ...
const patch = compiled.diff(loaded, current)
await fetch('/api/customer/42', { method: 'PUT', body: JSON.stringify(patch) })

The patch holds the difference and nothing else, so a masked field the user never touched is not in the request at all — it drops out because it is equal to what was loaded, not because of a rule about masks. Rows of an object_list are matched by $id: an untouched row contributes nothing, an edited one contributes only its changed fields, a deleted one contributes { $id, $deleted: true }. The server's Apply treats every absent key as unchanged, so the values that stayed behind stay behind.

diff() never clears a secret on its own: a form never reads a secret back, so "I do not have it" and "delete it" cannot be told apart from the values alone, and only one of the two can be undone if the guess is wrong. Deleting a secret stays something the caller says outright.

That is the whole of the protection, and it is worth being plain about why there is no second one: nothing marks a masked value as masked. '******6789' is a string like any other, so neither apply() nor validate() can tell it from something the user typed. A form that posts whole documents back will store the mask over the real value, and no rule on the server can stop it. Sending a patch is not an optimisation here.

Note the signature: the standalone redact(c, doc) takes the compiled schema, because the masks are read off it. It is the one function whose shape changed when masking landed; the alternative was a redact(doc) that silently returned unmasked values.

The type declarations

index.d.ts is hand-written, because the library itself is plain JavaScript that ships to a browser with no build step. Nothing about that arrangement compares the declarations to the code, which is how Field.metadata came to be declared Record<string, string> while a fixture had been carrying an array in it since the day it was written.

npm test now closes both ways they can drift:

  • test/declarations.test.js checks the names at runtime — every export of src/index.js against every export declare, and every wire interface against the key set the parser actually accepts.
  • test/types/smoke.ts, typechecked by npm run typecheck, checks the value types by holding the testdata/ fixtures to the declared interfaces and exercising the declared API surface.

Adding a field to the parser without declaring it, or declaring it as the wrong type, fails one of the two.

Parity with the Go implementation

Both implementations run against the same fixtures in testdata/ at the repository root, so they cannot drift apart without a test going red.

npm test

Known, deliberate differences:

  • Regular expressions. Go uses RE2; JavaScript uses its own engine. Leading inline flags ((?i), (?m), (?s)) are translated, but a pattern that relies on backtracking will be accepted here and rejected by RE2 on the server. Keep patterns simple.
  • Format checks (email, uri, hostname, ipv4, ipv6, uuid, duration, bytesize, cron, regex, date, time, datetime) are reimplemented against Go's standard-library behaviour, including the details that bite — leading zeros in IPv4 are rejected, ::ffff:a.b.c.d is not IPv6, duration follows time.ParseDuration. email is the loosest of these; a handful of exotic addresses mail.ParseAddress accepts may be rejected here.
  • Decimal comparison is exact, via BigInt, because a decimal field that compares as a double loses cents.
  • No Builder DSL. The Go side has one for assembling schemas in code; on the browser side a schema arrives as JSON, so there is nothing to build.

The server remains the authority. This library exists to make the form honest while the user is still typing, not to replace the check that happens after they press submit.

License

Apache-2.0, same as the rest of the repository.