jsonisch 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First public release. Extracted from the RWA platform with history intact.
6
+
7
+ Experimental: the API is in use in production there, but is not frozen.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Brown
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,218 @@
1
+ # jsonisch
2
+
3
+ > Schemas as values. A form library for apps where JSON-Schemas are **runtime
4
+ > data** — stored in a database, customized by admins, composed on the fly —
5
+ > and the whole form is derived from the schema value: state, validation,
6
+ > derived values, visibility, dirty-tracking.
7
+
8
+ **Status: experimental.** The API is in production in one app. It is not frozen.
9
+
10
+ ```sh
11
+ pnpm add jsonisch
12
+ # npm install jsonisch
13
+ # yarn add jsonisch
14
+ ```
15
+
16
+ ```ts
17
+ import { createFormStore } from "jsonisch";
18
+ import { createFormHook } from "jsonisch/react";
19
+ ```
20
+
21
+ React is an optional peer. The core store is DOM-free.
22
+
23
+ ---
24
+
25
+ ## The problem: schemas as values
26
+
27
+ Most form libraries assume the shape of your form is known at build time —
28
+ a Zod schema in a module, types inferred from it, a hand-written component
29
+ per field. That assumption breaks the moment your app lets users customize
30
+ their forms: now the schema is a **value**, fetched from a database at
31
+ request time, different per tenant, edited without a deploy. There is no
32
+ compile-time type to infer against, and nobody is hand-writing a component
33
+ per field for a form that didn't exist yesterday.
34
+
35
+ `jsonisch` starts from that world. It takes a JSON-Schema value, walks it
36
+ once, and gives you a fully reactive, validated form with the fields already
37
+ wired:
38
+
39
+ - **Validation** through an injected validator — the interface is
40
+ deliberately AJV-shaped, so a compiled AJV validate function passes
41
+ through unchanged (a first-party validation library may follow).
42
+ - **Rendering** through YOUR components — shadcn, your design system,
43
+ anything. You register widgets once, keyed by control kind; every schema
44
+ a tenant can invent renders through that one registry.
45
+ - **Derived values** through an injected calc engine that owns the
46
+ expression language; jsonisch owns the scope construction and the
47
+ derivation wiring (dependency graph, computed signals, exclusion from
48
+ dirty-tracking and the submit payload *by construction*).
49
+ - **Dirty-tracking, visibility, reset, submit** — driven by the schema
50
+ walk, not by per-field wiring.
51
+
52
+ You bring the components; it brings everything else.
53
+
54
+ ---
55
+
56
+ ## Quickstart
57
+
58
+ Write your widgets as plain controlled components — here with shadcn:
59
+
60
+ ```tsx
61
+ // widgets.tsx
62
+ import { Input } from "@/components/ui/input";
63
+ import { Label } from "@/components/ui/label";
64
+ import type { WidgetProps } from "jsonisch/react";
65
+
66
+ export function TextWidget({ field }: WidgetProps) {
67
+ return (
68
+ <div>
69
+ <Label htmlFor={field.name}>{field.schema.title ?? field.name}</Label>
70
+ <Input
71
+ id={field.name}
72
+ value={(field.input as string) ?? ""}
73
+ onChange={(e) => field.onChange(e.target.value)}
74
+ {...field.props}
75
+ />
76
+ {field.errors && <p className="text-sm text-destructive">{field.errors[0]}</p>}
77
+ </div>
78
+ );
79
+ }
80
+
81
+ export function CurrencyWidget({ field }: WidgetProps) {
82
+ /* same shape: field.input in, field.onChange out */
83
+ }
84
+
85
+ export function SelectWidget({ field }: WidgetProps) {
86
+ /* options come from field.schema.enum — the schema node rides along */
87
+ }
88
+ ```
89
+
90
+ Register them once, with a validator, at module level:
91
+
92
+ ```ts
93
+ // form.ts
94
+ import Ajv from "ajv";
95
+ import { createFormHook } from "jsonisch/react";
96
+ import { CurrencyWidget, SelectWidget, TextWidget } from "./widgets";
97
+
98
+ const ajv = new Ajv({ allErrors: true, strict: false });
99
+
100
+ export const { useAppForm, Form, Field } = createFormHook({
101
+ widgets: { text: TextWidget, currency: CurrencyWidget, select: SelectWidget },
102
+ validate: (schema) => {
103
+ const check = ajv.compile(schema);
104
+ return (input) => (check(input) ? null : check.errors);
105
+ },
106
+ });
107
+ ```
108
+
109
+ Then every form is two lines, no matter what schema shows up:
110
+
111
+ ```tsx
112
+ // Fetched from your database — a VALUE, not a type
113
+ const schema = {
114
+ type: "object",
115
+ required: ["borrowerName"],
116
+ properties: {
117
+ borrowerName: { type: "string", title: "Borrower name" },
118
+ loanAmount: {
119
+ type: "number",
120
+ title: "Loan amount",
121
+ "x-ui": { control: "currency" },
122
+ },
123
+ loanType: {
124
+ type: "string",
125
+ title: "Loan type",
126
+ enum: ["bridge", "construction", "rental"],
127
+ "x-ui": { control: "select" },
128
+ },
129
+ },
130
+ };
131
+
132
+ function LoanForm({ record }: { record: unknown }) {
133
+ const form = useAppForm({ schema, initialInput: record });
134
+ return <Form of={form} onSubmit={(output) => save(output)} />;
135
+ }
136
+ ```
137
+
138
+ `<Form>` without children renders the whole form from the schema through the
139
+ widget registry — no hand-written field components. `onSubmit` receives the
140
+ validated output; an invalid submit blocks the handler and focuses the first
141
+ erroring field. For custom layouts, `<Field of={form} path={["loanAmount"]} />`
142
+ places one registry-dispatched field, and a render-function child makes it
143
+ headless.
144
+
145
+ Widgets resolve by **control kind** — an explicit `x-ui.control` on the
146
+ schema node, or inferred from `format`/`type` (`inferControl` is exported).
147
+ A kind without a registry entry renders a visible fallback naming the
148
+ missing kind, so a schema misconfiguration can't silently drop a field.
149
+
150
+ Derived fields declare a formula on the schema node (`x-formula`), and the
151
+ form evaluates them reactively through a `CalcEngine` you inject as a
152
+ plugin — parse, evaluate, extract dependencies. The engine owns the
153
+ expression language; jsonisch builds the eval scopes (including the
154
+ root-record alias — `rootRecordAlias`, default `"record"` — the key stored
155
+ formulas use to address the root record) and wires the dependency graph.
156
+
157
+ ---
158
+
159
+ ## Where jsonisch sits — a deliberate "best of three"
160
+
161
+ `jsonisch` is a synthesis of three lineages, picking one idea from each:
162
+
163
+ - **From TanStack Form** — the **framework-agnostic core + thin adapters**
164
+ layout, and the **`createFormHook({ widgets })` composition/registry**
165
+ pattern: register your design-system widgets once, get a typed
166
+ `useAppForm` with them baked in.
167
+ - **From Formisch** — **signals** as the reactivity engine (its own, no
168
+ external signal lib), so a keystroke re-renders only the fields that
169
+ depend on it, and derived fields recompute automatically.
170
+ - **New here** — the schema kind is **JSON-Schema**, not Zod/Valibot/Yup.
171
+ Validation runs through an injected AJV-shaped validator. Derived values
172
+ are computed signals over the formulas already declared in the schema, so
173
+ they're excluded from dirty-tracking and the submit payload *by
174
+ construction*.
175
+
176
+ The re-render question — "when I type one character into a 60-field form,
177
+ what re-renders?" — has a two-decade history that signals largely closed,
178
+ and jsonisch inherits that answer from the formisch lineage rather than
179
+ contributing one. The full story, table and all, lives in
180
+ [docs/rerender-history.md](https://github.com/andrew310/jsonisch/blob/main/docs/rerender-history.md).
181
+
182
+ ### Design stance on types
183
+
184
+ TanStack Form's headline is deep, compile-time **path type-inference**.
185
+ `jsonisch` deliberately **drops it** — our schemas are runtime database
186
+ data, so field paths are runtime values with no compile-time shape to infer
187
+ against. The bet: keep *enough* typing that `tsc` stays a cheap,
188
+ deterministic tripwire on the code (still very much worth it), but skip the
189
+ galaxy-brain generics, and lean on **AJV + tests** for the runtime-shape
190
+ correctness that types can't cover for a schema that only exists at runtime
191
+ anyway.
192
+
193
+ ---
194
+
195
+ ## Architecture
196
+
197
+ - **core** — framework-agnostic store. `createFormStore(config, deps)` walks
198
+ the JSON-Schema once and builds a field-store tree
199
+ (`kind: array | object | value`), each node carrying signals
200
+ (`input`/`initialInput` for dirty-vs-reset, `errors`, `isDirty`, DOM
201
+ `elements`). DOM-free and isomorphic — the same walk runs server-side.
202
+ - **methods** — tree-shakeable ops: `setInput`, `validate`, `reset`,
203
+ `insert/move/remove/swap`, `handleSubmit`, `pickDirty`, `applyBaseline`.
204
+ - **react** — `createFormHook`, `useAppForm`, `<Form>`, headless `<Field>`.
205
+
206
+ Deeper dives, with diagrams, live in
207
+ [`docs/`](https://github.com/andrew310/jsonisch/tree/main/docs):
208
+
209
+ - [rerender-history.md](https://github.com/andrew310/jsonisch/blob/main/docs/rerender-history.md) — a short history of the form re-render problem, and what a signal is, concretely.
210
+ - [decode-fork.md](https://github.com/andrew310/jsonisch/blob/main/docs/decode-fork.md) — how a server record becomes `initialInput` (`x-column` routing, the envelope twin).
211
+ - [plugin-lifecycle.md](https://github.com/andrew310/jsonisch/blob/main/docs/plugin-lifecycle.md) — the pass order at build, and the reseed / rebase / reset / transfer sites.
212
+ - [wire-shapes.md](https://github.com/andrew310/jsonisch/blob/main/docs/wire-shapes.md) — the save payload partition, the persisted envelopes, and the LOS-461 skip policy.
213
+
214
+ ---
215
+
216
+ ## License / status
217
+
218
+ MIT. Nothing here is API-stable yet.