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.
@@ -0,0 +1,994 @@
1
+ //#region src/core/types/path.d.ts
2
+ /**
3
+ * One step of a path: an object property key or an array index.
4
+ */
5
+ type PathSegment = string | number;
6
+ /**
7
+ * A path from the form root to a field. Paths are runtime values — our
8
+ * schemas come from the database, so there is no type-level path inference.
9
+ */
10
+ type Path = PathSegment[];
11
+ //#endregion
12
+ //#region src/core/types/schema.d.ts
13
+ /**
14
+ * A JSON-Schema node as stored in the database.
15
+ *
16
+ * Structured over the keys the store walk and codec read; everything else
17
+ * (including the `x-*` vocabulary: `x-column`, `x-formula`, `x-ui`,
18
+ * `x-relation*`, …) is reachable through the index signature. Schemas are
19
+ * runtime DB data — there is no compile-time value inference.
20
+ */
21
+ interface JsonSchema {
22
+ /**
23
+ * The JSON-Schema type. May be a union list (e.g. `["string", "null"]`
24
+ * for nullable fields).
25
+ */
26
+ type?: string | string[];
27
+ /**
28
+ * Object properties. Presence makes the node an object field store.
29
+ */
30
+ properties?: Record<string, JsonSchema>;
31
+ /**
32
+ * Required property names of an object node. Properties not listed are
33
+ * optional and default to `undefined` instead of their empty input.
34
+ */
35
+ required?: string[];
36
+ /**
37
+ * Array item schema. A single schema makes the node an array field store;
38
+ * tuple form (an array of schemas) is not supported. An array-typed node
39
+ * WITHOUT items stays a value leaf whose value is the whole array (legacy
40
+ * relation arrays).
41
+ */
42
+ items?: JsonSchema | JsonSchema[];
43
+ /**
44
+ * String format hint (date, email, phone, currency, …).
45
+ */
46
+ format?: string;
47
+ /**
48
+ * Relation reference (`schema://…`). Makes the node a relation value leaf.
49
+ */
50
+ $ref?: string;
51
+ enum?: unknown[];
52
+ oneOf?: unknown[];
53
+ const?: unknown;
54
+ uniqueItems?: boolean;
55
+ additionalProperties?: unknown;
56
+ /**
57
+ * Our `x-*` vocabulary and any other JSON-Schema keywords.
58
+ */
59
+ [key: string]: unknown;
60
+ }
61
+ //#endregion
62
+ //#region src/core/signal.d.ts
63
+ /**
64
+ * Reactive signal primitive for jsonisch.
65
+ *
66
+ * A deliberately tiny push-based reactivity system built for a per-component
67
+ * subscription model (see `jsonisch/react` `useSignalSnapshot`):
68
+ *
69
+ * - Reads made while a listener is active (via `withListener`, or a
70
+ * `Tracker`'s `read`) subscribe that listener to the signal.
71
+ * - Subscriptions are ONE-SHOT: notifying a listener consumes its
72
+ * subscription. Listeners are expected to re-read (and thereby
73
+ * re-subscribe) as a consequence of being notified — exactly what a React
74
+ * component does when its render re-runs.
75
+ * - `computed` signals are lazy and cached. Staleness propagates through the
76
+ * computed graph eagerly (even inside `batch`), so a read always reflects
77
+ * the latest written inputs; only plain listener notifications (e.g.
78
+ * component re-renders) are deferred by `batch`.
79
+ *
80
+ * Known trade-off (documented, not a bug): outside `batch`, a plain listener
81
+ * whose `notify` synchronously reads other signals may observe a stale
82
+ * computed across a "diamond" dependency while sibling invalidations are
83
+ * still propagating. The React adapter is immune because `notify` only
84
+ * schedules a re-render; core methods that write multiple signals should use
85
+ * `batch`.
86
+ */
87
+ /**
88
+ * A writable reactive signal.
89
+ */
90
+ interface Signal<T> {
91
+ /**
92
+ * The value of the signal. Reading subscribes the active listener;
93
+ * writing a non-`Object.is`-equal value notifies subscribers.
94
+ */
95
+ value: T;
96
+ }
97
+ /**
98
+ * A read-only reactive signal (the shape returned by `computed`).
99
+ */
100
+ interface ReadonlySignal<T> {
101
+ /**
102
+ * The value of the signal. Reading subscribes the active listener.
103
+ */
104
+ readonly value: T;
105
+ }
106
+ /**
107
+ * A subscription target. One listener typically represents one component
108
+ * (React adapter) or one computed signal (internal).
109
+ */
110
+ interface Listener {
111
+ /**
112
+ * Notifies the listener that a subscribed signal changed. Deferred (and
113
+ * de-duplicated) while a `batch` is active.
114
+ */
115
+ notify: () => void;
116
+ /**
117
+ * The subscriber sets this listener is currently registered in. Used to
118
+ * clean up subscriptions when the listener goes away (e.g. unmount) and
119
+ * kept in sync by the one-shot notification cycle.
120
+ */
121
+ subscriptions: Set<Set<Listener>>;
122
+ /**
123
+ * Optional immediate invalidation hook. When present it runs synchronously
124
+ * at write time, even inside `batch`. Computed signals use it to mark
125
+ * themselves stale and propagate invalidation through the graph so that
126
+ * reads inside a batch never see stale cached values.
127
+ */
128
+ invalidate?: () => void;
129
+ }
130
+ /**
131
+ * Runs a function with the given listener active, restoring the previous
132
+ * listener afterwards (throw included). This is the ONLY way to activate a
133
+ * listener — a set-and-forget global (the v1 `setListener`) let one
134
+ * component's tracking window leak into whatever rendered next; the scoped
135
+ * form makes that structurally impossible.
136
+ *
137
+ * @param listener The listener to activate (or `undefined` to suspend
138
+ * tracking, the `untrack` case).
139
+ * @param fn The function whose signal reads subscribe the listener.
140
+ *
141
+ * @returns The return value of the function.
142
+ */
143
+ declare function withListener<T>(listener: Listener | undefined, fn: () => T): T;
144
+ /**
145
+ * Returns the currently active listener, if any.
146
+ *
147
+ * Advanced/internal: exposed for tests that verify subscription bookkeeping.
148
+ */
149
+ declare function getListener(): Listener | undefined;
150
+ /**
151
+ * A retained tracked-read handle: `read` subscribes its owner to every
152
+ * signal the function touches, `dispose` drops all current subscriptions.
153
+ */
154
+ interface Tracker {
155
+ /**
156
+ * Runs the function with the tracker's listener active and returns its
157
+ * result. One-shot semantics apply: a notification consumes the
158
+ * subscriptions, so the notified party re-reads (and thereby
159
+ * re-subscribes) to stay live.
160
+ */
161
+ readonly read: <T>(fn: () => T) => T;
162
+ /**
163
+ * Drops every current subscription. The tracker stays usable — a later
164
+ * `read` re-subscribes.
165
+ */
166
+ readonly dispose: () => void;
167
+ }
168
+ /**
169
+ * Creates a tracker: the non-React primitive the react adapter's snapshot
170
+ * store is built on. `onInvalidate` fires when any signal read during the
171
+ * last `read` changes (deferred and de-duplicated by an active `batch`).
172
+ *
173
+ * @param onInvalidate Called when a tracked signal changes.
174
+ *
175
+ * @returns The created tracker.
176
+ */
177
+ declare function createTracker(onInvalidate: () => void): Tracker;
178
+ /**
179
+ * Creates a writable reactive signal without an initial value.
180
+ *
181
+ * @returns The created signal.
182
+ */
183
+ declare function createSignal<T>(): Signal<T | undefined>;
184
+ /**
185
+ * Creates a writable reactive signal with an initial value.
186
+ *
187
+ * @param initialValue The initial value.
188
+ *
189
+ * @returns The created signal.
190
+ */
191
+ declare function createSignal<T>(initialValue: T): Signal<T>;
192
+ /**
193
+ * Creates a lazy, cached, read-only signal derived from other signals.
194
+ *
195
+ * The compute function runs on first read and again after any signal it read
196
+ * during its last run changes (dependencies are re-tracked on every run, so
197
+ * conditional reads narrow or widen the dependency set). Subscribers of the
198
+ * computed are notified when it is invalidated; the fresh value is produced
199
+ * on the next read.
200
+ *
201
+ * @param compute The function deriving the value.
202
+ *
203
+ * @returns The created read-only signal.
204
+ */
205
+ declare function computed<T>(compute: () => T): ReadonlySignal<T>;
206
+ /**
207
+ * Batches signal writes: plain listener notifications (e.g. component
208
+ * re-renders) are collected, de-duplicated, and delivered once when the
209
+ * outermost batch ends. Computed invalidation is NOT deferred, so reads
210
+ * inside the batch observe the written values.
211
+ *
212
+ * @param fn The function to execute in the batch.
213
+ *
214
+ * @returns The return value of the function.
215
+ */
216
+ declare function batch<T>(fn: () => T): T;
217
+ /**
218
+ * Executes a function without tracking signal reads as subscriptions.
219
+ *
220
+ * @param fn The function to execute untracked.
221
+ *
222
+ * @returns The return value of the function.
223
+ */
224
+ declare function untrack<T>(fn: () => T): T;
225
+ //#endregion
226
+ //#region src/core/control.d.ts
227
+ /**
228
+ * The UI-side vocabulary: what kind of widget a field renders as. Distinct
229
+ * from JSON-Schema `type` — e.g. "string" can map to many kinds (text,
230
+ * email, currency, …).
231
+ *
232
+ * Closed set, deliberately opinionated (US-lending-flavored: ein, ssn,
233
+ * us-state, legal-id, interest-rate, line-item); widgets are host-supplied,
234
+ * so unused kinds cost nothing. Add explicitly when a new widget exists.
235
+ */
236
+ declare const CONTROL_KINDS: readonly ["text", "textarea", "number", "boolean", "date", "address", "email", "email-array", "phone", "phone-array", "link-array", "currency", "percent", "interest-rate", "ein", "ssn", "us-state", "legal-id", "select", "multiselect", "formula", "estimate", "amount-or-percent", "line-item", "object-array", "hidden"];
237
+ type ControlKind = (typeof CONTROL_KINDS)[number];
238
+ /**
239
+ * Decides which UI widget kind a JSON-Schema node renders as.
240
+ *
241
+ * Precedence:
242
+ * 1. Relations — `$ref`, array-of-`$ref`, or `x-relation`/flat
243
+ * `x-relation-target`. Single → select, many → multiselect.
244
+ * 2. Explicit `x-ui.control`, plus the `estimate: true` attribute
245
+ * promoting a formula to an estimate.
246
+ * 3. `format` — JSON-Schema standard widget hints on strings.
247
+ * 4. `type` — JSON-Schema primitive fallback.
248
+ *
249
+ * @param schema The JSON-Schema node.
250
+ *
251
+ * @returns The control kind.
252
+ */
253
+ declare function inferControl(schema: JsonSchema): ControlKind;
254
+ //#endregion
255
+ //#region src/core/types/visibility.d.ts
256
+ /**
257
+ * The comparison operator of a `VisibleWhen` condition. `equals`/`one-of`/
258
+ * `contains` come from a schema `then` branch; the `else` branch flips them
259
+ * to the `not-` variants. `one-of` (LOS-822) carries an array `value` and
260
+ * passes while the watched value equals ANY element.
261
+ */
262
+ type VisibleWhenOp = "equals" | "not-equals" | "one-of" | "not-one-of" | "contains" | "not-contains";
263
+ /**
264
+ * A resolved conditional-visibility rule: the field renders only while the
265
+ * watched field's value satisfies the condition. Resolved once at store
266
+ * init from the root schema's `allOf` `if/then/else` blocks; hidden values
267
+ * are RETAINED (visibility gates rendering, never state or payload).
268
+ */
269
+ interface VisibleWhen {
270
+ /**
271
+ * The watched field reference: a root-level field key, or the
272
+ * root-record bracket form (`record[key]` by default) reading `key` off the
273
+ * object stored under the form's `rootRecordAlias` in the eval scope (the
274
+ * host layers into `offFormValues` — LOS-463/LOS-471).
275
+ */
276
+ readonly field: string;
277
+ /**
278
+ * The comparison operator.
279
+ */
280
+ readonly op: VisibleWhenOp;
281
+ /**
282
+ * The value the watched field is compared against.
283
+ */
284
+ readonly value: unknown;
285
+ }
286
+ //#endregion
287
+ //#region src/core/types/field.d.ts
288
+ /**
289
+ * The structural kind of a field store node, mirroring the JSON-Schema
290
+ * shape: `properties` → object, single-schema `items` → array, everything
291
+ * else (including `items`-less array-typed nodes, whose whole array is the
292
+ * value) → value. Orthogonal to `ControlKind`, the widget vocabulary.
293
+ */
294
+ type FieldKind = "array" | "object" | "value";
295
+ /**
296
+ * A DOM element a value field can be bound to.
297
+ *
298
+ * Type-only reference: core never touches the DOM at runtime and stays
299
+ * isomorphic; the react adapter populates these.
300
+ */
301
+ type FieldElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
302
+ /**
303
+ * The errors of a field: at least one message, or `null` when valid.
304
+ * Validation issues (AJV) and calc errors land in the same channel.
305
+ */
306
+ type FieldErrors = [string, ...string[]] | null;
307
+ /**
308
+ * State shared by every field store node.
309
+ */
310
+ interface InternalBaseStore {
311
+ /**
312
+ * The kind of field store.
313
+ */
314
+ kind: FieldKind;
315
+ /**
316
+ * The property name of the field (last path segment as a string).
317
+ */
318
+ name: string;
319
+ /**
320
+ * The path from the form root to the field.
321
+ */
322
+ path: Path;
323
+ /**
324
+ * The JSON-Schema node this field was derived from.
325
+ */
326
+ schema: JsonSchema;
327
+ /**
328
+ * The widget kind this field renders as, resolved once at walk time via
329
+ * `inferControl`.
330
+ */
331
+ control: ControlKind;
332
+ /**
333
+ * Whether the field accepts a nullish value: its `type` includes `"null"`
334
+ * or its key is not in the parent's `required` list. Nullish fields keep
335
+ * `undefined`/`null` instead of defaulting to their empty input.
336
+ */
337
+ isNullish: boolean;
338
+ /**
339
+ * The DOM elements bound to the field (react adapter only; empty on the
340
+ * server).
341
+ */
342
+ elements: FieldElement[];
343
+ /**
344
+ * The elements the field registered itself (its reset baseline). Array
345
+ * methods move `elements` between field stores during reorders; `reset`
346
+ * restores each field's original elements via `elements = initialElements`.
347
+ * Starts as the SAME array reference as `elements` so registrations made
348
+ * before any reorder land in both.
349
+ */
350
+ initialElements: FieldElement[];
351
+ /**
352
+ * The errors of the field — the ONE read channel (validation + calc).
353
+ * For most fields this is the same object as `validationErrors`; a
354
+ * formula field swaps in a computed composing `validationErrors` with
355
+ * its derived signal's calc error, so validation passes can never
356
+ * clobber a calc error (and vice versa).
357
+ */
358
+ errors: ReadonlySignal<FieldErrors>;
359
+ /**
360
+ * The validation-sourced errors of the field. The ONLY writable error
361
+ * store — every writer (validate routing, `setErrors`, reset, item-state
362
+ * transfer) goes through this; calc errors are derived, never written.
363
+ */
364
+ validationErrors: Signal<FieldErrors>;
365
+ /**
366
+ * Whether the field has been focused.
367
+ */
368
+ isTouched: Signal<boolean>;
369
+ /**
370
+ * Whether the field's value has ever been changed. Unlike `isDirty` it
371
+ * stays `true` when the value is changed back; only a reset clears it.
372
+ */
373
+ isEdited: Signal<boolean>;
374
+ /**
375
+ * Whether the field's value differs from its start input (semantic,
376
+ * empty-aware compare: `null` ≡ `undefined` ≡ `""`).
377
+ */
378
+ isDirty: Signal<boolean>;
379
+ /**
380
+ * The conditional-visibility rule of the field, resolved once at store
381
+ * init from the root schema's `allOf` `if/then/else` blocks. Root-level
382
+ * fields only (derivation reaches into rows, visibility does not yet);
383
+ * absent on ungated fields.
384
+ */
385
+ visibleWhen?: VisibleWhen | undefined;
386
+ /**
387
+ * Whether the field currently renders: a computed over the watched
388
+ * field's resolved value (form wins, `offFormValues` fills). Present
389
+ * only alongside `visibleWhen` — absent means always visible. Gates
390
+ * RENDERING only; a hidden field keeps its state, dirtiness, and place
391
+ * in the payload.
392
+ */
393
+ visible?: ReadonlySignal<boolean> | undefined;
394
+ }
395
+ /**
396
+ * Presence marker for container (array/object) inputs: `true` when the value
397
+ * lives in the children, or the nullish value itself.
398
+ */
399
+ type ContainerInput = true | null | undefined;
400
+ /**
401
+ * An array field store node.
402
+ */
403
+ interface InternalArrayStore extends InternalBaseStore {
404
+ kind: "array";
405
+ /**
406
+ * The single item schema, validated non-tuple at walk time (so growth
407
+ * paths never re-trust `schema.items`).
408
+ */
409
+ itemSchema: JsonSchema;
410
+ /**
411
+ * The child stores, one per array item.
412
+ */
413
+ children: InternalFieldStore[];
414
+ /**
415
+ * The initial input presence (reset target; does not move with items).
416
+ */
417
+ initialInput: Signal<ContainerInput>;
418
+ /**
419
+ * The start input presence (dirty baseline; moves with items).
420
+ */
421
+ startInput: Signal<ContainerInput>;
422
+ /**
423
+ * The current input presence.
424
+ */
425
+ input: Signal<ContainerInput>;
426
+ /**
427
+ * The initial item IDs (reset target; does not move with items).
428
+ */
429
+ initialItems: Signal<string[]>;
430
+ /**
431
+ * The start item IDs (dirty baseline for length changes).
432
+ */
433
+ startItems: Signal<string[]>;
434
+ /**
435
+ * The current item IDs, one stable ID per array item. The item count is
436
+ * the authoritative array length (`children` may hold stale stores past
437
+ * the end after a shrink, kept for baseline reuse on regrow).
438
+ */
439
+ items: Signal<string[]>;
440
+ }
441
+ /**
442
+ * An object field store node.
443
+ */
444
+ interface InternalObjectStore extends InternalBaseStore {
445
+ kind: "object";
446
+ /**
447
+ * The child stores, keyed by property name.
448
+ */
449
+ children: Record<string, InternalFieldStore>;
450
+ /**
451
+ * The initial input presence (reset target).
452
+ */
453
+ initialInput: Signal<ContainerInput>;
454
+ /**
455
+ * The start input presence (dirty baseline).
456
+ */
457
+ startInput: Signal<ContainerInput>;
458
+ /**
459
+ * The current input presence.
460
+ */
461
+ input: Signal<ContainerInput>;
462
+ }
463
+ /**
464
+ * A leaf value field store node.
465
+ *
466
+ * Inputs are `unknown` — our schemas are runtime DB data, so there is no
467
+ * compile-time value inference.
468
+ *
469
+ * Feature state (derivation channels, envelope meta, visibility beyond
470
+ * the base rule) lives in plugin slots keyed by this store's identity
471
+ * (`FieldSlotKey`), never here — core's field shape has no compile-time
472
+ * dependency on any feature.
473
+ */
474
+ interface InternalValueStore extends InternalBaseStore {
475
+ kind: "value";
476
+ /**
477
+ * The initial input (reset target).
478
+ */
479
+ initialInput: Signal<unknown>;
480
+ /**
481
+ * The start input (dirty baseline; `applyBaseline` may update it).
482
+ */
483
+ startInput: Signal<unknown>;
484
+ /**
485
+ * The current input.
486
+ */
487
+ input: Signal<unknown>;
488
+ }
489
+ /**
490
+ * Any field store node.
491
+ */
492
+ type InternalFieldStore = InternalArrayStore | InternalObjectStore | InternalValueStore;
493
+ //#endregion
494
+ //#region src/core/plugin/key.d.ts
495
+ declare const stateBrand: unique symbol;
496
+ /**
497
+ * The identity a plugin's state is stored under in the form store's
498
+ * `pluginState` map (the ProseMirror pattern, minus its string-name
499
+ * registry). The generic is a phantom — it exists only so `getState`
500
+ * returns the right type; nothing is stored on the key itself.
501
+ *
502
+ * Keys are module-level singletons exported next to their plugin factory.
503
+ * Cross-plugin reads go through the exported key (derivation imports
504
+ * `envelopesKey`, never the envelopes implementation), and stay
505
+ * `T | undefined` — a form without the owning plugin is a legal config.
506
+ */
507
+ declare class PluginKey<TState> {
508
+ readonly name: string;
509
+ private readonly [stateBrand];
510
+ constructor(name: string);
511
+ /**
512
+ * Reads the owning plugin's state off a form store, or `undefined` when
513
+ * the plugin is not registered on this form.
514
+ */
515
+ getState(form: InternalFormStore): TState | undefined;
516
+ }
517
+ /**
518
+ * A plugin key whose state is a per-field map keyed by field-store
519
+ * IDENTITY. Field stores are position-fixed — array operations move signal
520
+ * VALUES between stores, not the stores themselves — so per-field slots
521
+ * must transfer with item state via the `transferField`/`swapField` hooks,
522
+ * exactly like the input signals they sit next to.
523
+ */
524
+ declare class FieldSlotKey<TSlot> extends PluginKey<Map<InternalFieldStore, TSlot>> {
525
+ /**
526
+ * Reads one field's slot, or `undefined` when the plugin is absent or the
527
+ * field carries no slot (a bare scalar to this plugin).
528
+ */
529
+ get(form: InternalFormStore, store: InternalFieldStore): TSlot | undefined;
530
+ }
531
+ //#endregion
532
+ //#region src/core/plugin/types.d.ts
533
+ /**
534
+ * A persisted wire entry split into its halves: the field's own value and
535
+ * the plugin-owned meta channel nested next to it.
536
+ */
537
+ interface WireEnvelope {
538
+ readonly value: unknown;
539
+ readonly meta: Record<string, unknown>;
540
+ }
541
+ /**
542
+ * A plugin's STATIC wire behavior — a plain descriptor exported next to the
543
+ * plugin factory, never runtime state, because the codec is isomorphic: the
544
+ * server imports the same descriptor for save routing (`encodeDirty`) with
545
+ * no form store anywhere (D7).
546
+ */
547
+ interface WireContract {
548
+ /**
549
+ * The controls whose persisted shape is the kind-discriminated envelope.
550
+ * Core's decode paths unwrap these at the value leaf; everything else
551
+ * stays a bare value.
552
+ */
553
+ readonly envelopeControls?: readonly string[];
554
+ /**
555
+ * Splits a raw persisted entry into its halves. Non-envelope raw (bad
556
+ * data) must decode defensively as `{ value: raw, meta: {} }`.
557
+ */
558
+ readonly unwrap?: (raw: unknown) => WireEnvelope;
559
+ /**
560
+ * Returns whether a declared key's outgoing value must be dropped from
561
+ * the save payload entirely (`encodeDirty`) — e.g. a formula value, which
562
+ * is always server-recomputed.
563
+ */
564
+ readonly skipValue?: (control: string, dirty: Record<string, unknown>, key: string) => boolean;
565
+ /**
566
+ * Normalizes a declared key's outgoing value in `encodeDirty` — the
567
+ * server-side enforcement twin of the client plugin's `encodeValue` (e.g.
568
+ * stripping the value half of an estimate envelope that does not pin
569
+ * `mode: "estimate"`). Returning `undefined` drops the key.
570
+ */
571
+ readonly encode?: (control: string, raw: unknown) => unknown;
572
+ }
573
+ /**
574
+ * The context every per-form plugin hook receives: the form store and the
575
+ * plugin's own state (created by its `build`).
576
+ */
577
+ interface PluginCtx<TState> {
578
+ readonly form: InternalFormStore;
579
+ readonly state: TState;
580
+ }
581
+ /**
582
+ * A jsonisch plugin: a plain object from a factory. Config lives in the
583
+ * factory closure; all mutable state is created inside `build`, keyed to
584
+ * the form store (a module-scope-hoisted plugin shared by two open forms
585
+ * must not cross-write). Registration happens only in the `createFormStore`
586
+ * config — no late registration.
587
+ *
588
+ * Every hook is sync — passes run inside `batch(() => untrack(() => …))`
589
+ * and an `await` there would let React render a half-rebased form.
590
+ *
591
+ * Core owns the phases: each hook is a pinned call site in core (a plugin
592
+ * can never sort itself before the value rebase). Plugins only order among
593
+ * themselves — array order within each hook, validated by `dependsOn`.
594
+ */
595
+ interface JsonischPlugin<TState = unknown> {
596
+ /**
597
+ * The plugin name (error attribution + uniqueness).
598
+ */
599
+ readonly name: string;
600
+ /**
601
+ * The identity the plugin's state is stored under. Exported as a
602
+ * module-level singleton so other plugins can read this plugin's state
603
+ * without importing its implementation.
604
+ */
605
+ readonly key: PluginKey<TState>;
606
+ /**
607
+ * Keys of plugins that must be registered EARLIER in the plugins array.
608
+ * `createFormStore` throws when one is missing or later — turning
609
+ * ordering comments into hard startup errors (the estimate-pin data-loss
610
+ * scenario of D2).
611
+ */
612
+ readonly dependsOn?: readonly PluginKey<unknown>[];
613
+ /**
614
+ * The plugin's static wire contract, collected onto the form store so
615
+ * core's decode paths can unwrap envelopes. The same descriptor object is
616
+ * exported next to the factory for the server-side `encodeDirty`.
617
+ */
618
+ readonly wire?: WireContract;
619
+ /**
620
+ * 1 — creates the plugin's state container, BEFORE the schema walk (so
621
+ * the walk can dispatch `buildScope` for the rows it creates). No tree
622
+ * access here — scope work belongs in `buildScope`.
623
+ */
624
+ build(form: InternalFormStore, config: FormConfig): TState;
625
+ /**
626
+ * 2 — wires one object scope, in plugin array order: the form root
627
+ * (after the walk, with the raw initial input) and every array-item
628
+ * object AS THE WALK CREATES IT (with the raw row object). A row created
629
+ * by an insert or a whole-array write behaves exactly like one the
630
+ * record loaded with.
631
+ */
632
+ buildScope?(ctx: PluginCtx<TState>, scope: InternalObjectStore, raw: unknown): void;
633
+ /**
634
+ * 3 — re-seeds an EXISTING row scope that adopts a different row (an
635
+ * array shrink-then-regrow, a grown baseline row). Signals must be
636
+ * re-seeded in place, never replaced — computeds already wired to them
637
+ * keep tracking.
638
+ */
639
+ reseedScope?(ctx: PluginCtx<TState>, scope: InternalObjectStore, raw: unknown): void;
640
+ /**
641
+ * 4 — per value leaf, from INSIDE reset's existing walk (scoped resets
642
+ * stay correct for free). Restore live plugin state to its decode-time
643
+ * baseline.
644
+ */
645
+ resetField?(ctx: PluginCtx<TState>, store: InternalFieldStore): void;
646
+ /**
647
+ * 4b — live input write for a value leaf (`setFieldInput`). Return
648
+ * `true` when the plugin wrote `store.input` (and its own slot) so core
649
+ * does not write the leaf again. Envelope fields use this so a
650
+ * keystroke updates `envelope.value` through `writeEnvelope`.
651
+ */
652
+ syncInput?(ctx: PluginCtx<TState>, store: InternalValueStore, input: unknown): boolean;
653
+ /**
654
+ * 4c — `reset({ initialInput })` has just written this leaf's
655
+ * `initialInput` from `raw`. Re-decode the start envelope from that
656
+ * same raw so `resetField` restores number and mode from one object.
657
+ * Not called from `applyBaseline` (rebase owns that adopt).
658
+ */
659
+ syncInitial?(ctx: PluginCtx<TState>, store: InternalValueStore, raw: unknown): void;
660
+ /**
661
+ * 5 — after the value/baseline rebase of one object scope: the form root
662
+ * (from `applyBaseline`, with the raw decoded record) and each array-item
663
+ * object (from the baseline rebase walk, with the fresh raw row). The
664
+ * plugin owns its own decode of the raw.
665
+ */
666
+ rebase?(ctx: PluginCtx<TState>, scope: InternalObjectStore, raw: unknown): void;
667
+ /**
668
+ * 6 — item-state transfer: per-field plugin state travels with its row
669
+ * through an insert, remove or move, exactly like the input signals
670
+ * (`copyItemState`). Field stores are position-fixed; only values move.
671
+ */
672
+ transferField?(ctx: PluginCtx<TState>, from: InternalValueStore, to: InternalValueStore): void;
673
+ /**
674
+ * 7 — the swap twin of `transferField` (`swapItemState`).
675
+ */
676
+ swapField?(ctx: PluginCtx<TState>, first: InternalValueStore, second: InternalValueStore): void;
677
+ /**
678
+ * 8 — whether THIS field's plugin state is dirty (must serialize). Feeds
679
+ * the subtree dirty walks that decide payload emission.
680
+ */
681
+ fieldIsDirty?(ctx: PluginCtx<TState>, store: InternalValueStore): boolean;
682
+ /**
683
+ * 9 — wraps a value field's own payload entry (the LOS-573 envelope):
684
+ * called for a dirty leaf with its outgoing value (`undefined` when only
685
+ * the plugin half is dirty), returns the wire entry to emit — or
686
+ * `undefined` for "no opinion" (a bare scalar). Two plugins claiming the
687
+ * same field throw.
688
+ */
689
+ encodeValue?(ctx: PluginCtx<TState>, store: InternalValueStore, valueOut: unknown): unknown;
690
+ /**
691
+ * 10 — the plugin's contribution to form-level `isDirty`. Runs inside a
692
+ * computed, so it MUST read its signals unconditionally — the aggregate
693
+ * never short-circuits between plugins (an unran handler contributes no
694
+ * signal reads and deafens the projection).
695
+ */
696
+ isDirty?(ctx: PluginCtx<TState>): boolean;
697
+ /**
698
+ * 11 — members merged into the react field snapshot, computed inside the
699
+ * library-owned tracked read (the D5 snapshot model): plain values read
700
+ * off signals — never getters — plus identity-stable callbacks (a fresh
701
+ * closure per call would defeat the snapshot equality gate and re-render
702
+ * every notification; cache them on the plugin's per-field state). Types
703
+ * ride the `FieldStoreSlots` declare-module augmentation in the plugin's
704
+ * own file. A key claimed twice, or colliding with a core field member,
705
+ * throws.
706
+ */
707
+ fieldSnapshot?(ctx: PluginCtx<TState>, store: InternalFieldStore, path: Path): Record<string, unknown>;
708
+ }
709
+ /**
710
+ * The `plugins` config entry: factories may be composed conditionally —
711
+ * falsy entries and nested arrays are accepted and flattened
712
+ * (`plugins: [envelopes(), engine && derivation(engine)]`).
713
+ */
714
+ type PluginsInput = ReadonlyArray<JsonischPlugin<unknown> | false | null | undefined | ReadonlyArray<JsonischPlugin<unknown> | false | null | undefined>>;
715
+ //#endregion
716
+ //#region src/core/plugin/driver.d.ts
717
+ /**
718
+ * The hooks the driver precomputes implementer lists for (the per-field
719
+ * ones run in loops; scanning all plugins per field would be quadratic).
720
+ */
721
+ declare const HOOK_NAMES: readonly ["buildScope", "reseedScope", "resetField", "syncInput", "syncInitial", "rebase", "transferField", "swapField", "fieldIsDirty", "encodeValue", "isDirty", "fieldSnapshot"];
722
+ type HookName = (typeof HOOK_NAMES)[number];
723
+ /**
724
+ * The resolved plugin runtime on a form store: the flat plugin list,
725
+ * per-hook implementer lists, and the envelope wire contracts keyed by
726
+ * control kind — all precomputed once at `createFormStore`.
727
+ */
728
+ interface PluginDriver {
729
+ readonly plugins: readonly JsonischPlugin<unknown>[];
730
+ readonly hooks: Readonly<Record<HookName, readonly JsonischPlugin<unknown>[]>>;
731
+ /**
732
+ * Envelope wire contracts by control kind (`estimate` →
733
+ * `envelopesWire`), consulted by the leaf decode paths.
734
+ */
735
+ readonly envelopes: ReadonlyMap<string, WireContract>;
736
+ }
737
+ /**
738
+ * Whether any plugin's form-level state is dirty. Runs inside the
739
+ * `isDirty` aggregate computed, so it reads EVERY implementer — never
740
+ * short-circuits: an unran handler contributes no signal reads and would
741
+ * deafen the projection.
742
+ */
743
+ declare function pluginsDirty(form: InternalFormStore): boolean;
744
+ /**
745
+ * Whether THIS value leaf's plugin state is dirty (must serialize). Reads
746
+ * every implementer for the same no-short-circuit reason as `pluginsDirty`
747
+ * — callers may sit inside computeds.
748
+ */
749
+ declare function fieldPluginDirty(form: InternalFormStore, store: InternalValueStore): boolean;
750
+ /**
751
+ * Whether any value leaf in the subtree has dirty plugin state — the
752
+ * plugin half of the dirty walks that decide payload emission (a mode flip
753
+ * with an unchanged value must still produce a payload). Reads array
754
+ * `items` (not raw `children`, which may hold stale stores past the end
755
+ * after a shrink), so a reactive caller subscribes to structural changes.
756
+ */
757
+ declare function hasPluginDirtyField(form: InternalFormStore, store: InternalFieldStore): boolean;
758
+ /**
759
+ * Encodes one dirty value leaf's payload entry: asks the `encodeValue`
760
+ * implementers to wrap it (the LOS-573 envelope). Exactly one plugin may
761
+ * claim a field; with no claim the outgoing value is emitted as-is.
762
+ *
763
+ * @param form The form store.
764
+ * @param store The value leaf being encoded.
765
+ * @param valueOut The outgoing value (`undefined` when only plugin state
766
+ * is dirty).
767
+ *
768
+ * @returns The wire entry to emit.
769
+ */
770
+ declare function encodeFieldValue(form: InternalFormStore, store: InternalValueStore, valueOut: unknown): unknown;
771
+ /**
772
+ * Unwraps a raw persisted leaf entry through the form's envelope wire
773
+ * contracts: an envelope-control leaf resolves its value half, everything
774
+ * else passes through. The single decode seam shared by the walk, reset,
775
+ * and the baseline rebase — a leaf can never round-trip to two different
776
+ * values.
777
+ */
778
+ declare function unwrapLeafInput(form: InternalFormStore, control: string, raw: unknown): unknown;
779
+ //#endregion
780
+ //#region src/core/types/form.d.ts
781
+ /**
782
+ * When validation runs. `validate` is the mode that arms validation the
783
+ * first time; `revalidate` takes over once the form is in the "already
784
+ * validated" state (submitted, or the triggering subtree has errors) — the
785
+ * formisch two-mode model: validate on submit, revalidate on input.
786
+ */
787
+ type ValidationMode = "initial" | "touch" | "input" | "change" | "blur" | "submit";
788
+ /**
789
+ * One validation issue in the injected validator's output. Deliberately
790
+ * AJV-shaped (`ErrorObject` subset) so an app can pass a compiled AJV
791
+ * validate function's `errors` through unchanged — jsonisch itself never
792
+ * depends on AJV.
793
+ */
794
+ interface ValidationIssue {
795
+ /**
796
+ * JSON-Pointer to the failing value (`""` for the root). Routed to the
797
+ * field store's `errors` signal; an unroutable pointer lands on the
798
+ * nearest addressable ancestor.
799
+ */
800
+ readonly instancePath?: string | undefined;
801
+ /**
802
+ * The human-readable message. Falls back to a generic message when absent.
803
+ */
804
+ readonly message?: string | undefined;
805
+ /**
806
+ * The failed JSON-Schema keyword. `required` issues point at the parent
807
+ * object; routing appends `params.missingProperty` so the error lands on
808
+ * the missing field itself.
809
+ */
810
+ readonly keyword?: string | undefined;
811
+ /**
812
+ * Keyword-specific parameters (e.g. `missingProperty` for `required`).
813
+ */
814
+ readonly params?: Record<string, unknown> | undefined;
815
+ }
816
+ /**
817
+ * The injected validator: compiled ONCE per schema by the caller, returns
818
+ * the issues for an input (`null`/`undefined`/empty for a valid input).
819
+ * Synchronous by design — AJV is sync.
820
+ */
821
+ type FormValidator = (input: unknown) => readonly ValidationIssue[] | null | undefined;
822
+ /**
823
+ * Configuration for creating a form store.
824
+ */
825
+ interface FormConfig {
826
+ /**
827
+ * The JSON-Schema the form is derived from (runtime DB data).
828
+ */
829
+ readonly schema: JsonSchema;
830
+ /**
831
+ * The initial input in the nested server-record shape (no pre-flattening).
832
+ * Keys not declared in the schema never enter form state (the schema is
833
+ * the allow-list).
834
+ */
835
+ readonly initialInput?: unknown;
836
+ /**
837
+ * Read-only eval scope for formula resolution (off-form values). Becomes a
838
+ * settable signal on the store; `applyBaseline` updates it.
839
+ */
840
+ readonly offFormValues?: Record<string, unknown>;
841
+ /**
842
+ * The identifier the host's stored formulas use for the root record
843
+ * (`record` by default). Why it exists: a row's eval scope is sealed to
844
+ * its own columns, so a per-row calc that needs a root value — an
845
+ * asset's share of `loan[totalLoanAmount]` — can only reach the root
846
+ * through this one name. Reserved in row scope (it wins over a row
847
+ * column of the same name); hosts whose schemas already say `loan[…]`
848
+ * pass `"loan"`.
849
+ */
850
+ readonly rootRecordAlias?: string | undefined;
851
+ /**
852
+ * The registered plugins, run in array order within each hook. Falsy
853
+ * entries and one level of nesting are accepted
854
+ * (`plugins: [envelopes(), engine && derivation(engine)]`). Everything
855
+ * computed on top of the base pipeline — envelope meta state,
856
+ * derivation, visibility — registers here; a form without plugins is a
857
+ * plain schema-walked value store.
858
+ */
859
+ readonly plugins?: PluginsInput | undefined;
860
+ /**
861
+ * The empty input a required field without an initial input starts at,
862
+ * keyed by JSON-Schema type. Merged over the default (`{ string: "" }` —
863
+ * required strings start as `""`, every other type as `undefined`).
864
+ */
865
+ readonly emptyInput?: Record<string, unknown>;
866
+ /**
867
+ * The injected validator, compiled once per schema by the caller. Without
868
+ * one the form always validates successfully (enforcement rollout is
869
+ * per-form opt-in).
870
+ */
871
+ readonly validator?: FormValidator | undefined;
872
+ /**
873
+ * The validation mode of the form. Defaults to `"submit"`.
874
+ */
875
+ readonly validate?: ValidationMode | undefined;
876
+ /**
877
+ * The revalidation mode of the form. Defaults to `"input"`.
878
+ */
879
+ readonly revalidate?: Exclude<ValidationMode, "initial"> | undefined;
880
+ }
881
+ /**
882
+ * The `computed`-cached form-level aggregates. Each one wraps a whole-tree
883
+ * `getFieldBool` walk (plus the meta channel for `isDirty`) so the walk
884
+ * runs once per invalidation instead of once per read — under the react
885
+ * adapter's snapshot model `useForm` reads these on every notification,
886
+ * which without caching would mean four full-tree walks per keystroke.
887
+ */
888
+ interface FormAggregates {
889
+ /**
890
+ * Whether any field in the form has been touched.
891
+ */
892
+ readonly isTouched: ReadonlySignal<boolean>;
893
+ /**
894
+ * Whether any field in the form has been edited.
895
+ */
896
+ readonly isEdited: ReadonlySignal<boolean>;
897
+ /**
898
+ * Whether any field differs from its start input, OR any root-level
899
+ * field's meta channel is dirty (a mode flip with an unchanged value
900
+ * still produces a payload, so Save must enable).
901
+ */
902
+ readonly isDirty: ReadonlySignal<boolean>;
903
+ /**
904
+ * Whether no field in the form has validation errors. Calc errors are
905
+ * excluded — only user-fixable validation gates validity.
906
+ */
907
+ readonly isValid: ReadonlySignal<boolean>;
908
+ }
909
+ /**
910
+ * The internal form store: the root object node plus form-level state.
911
+ */
912
+ interface InternalFormStore extends InternalObjectStore {
913
+ /**
914
+ * The resolved empty-input config (defaults merged with the form config),
915
+ * read by the walk when defaulting required fields without initial input.
916
+ */
917
+ emptyInput: Record<string, unknown>;
918
+ /**
919
+ * The resolved root-record alias (config value or the default) —
920
+ * the one home both the shelf alias writer and the scope resolvers read.
921
+ */
922
+ rootRecordAlias: string;
923
+ /**
924
+ * The injected validator, or `undefined` for a form without enforcement.
925
+ */
926
+ validator: FormValidator | undefined;
927
+ /**
928
+ * The resolved plugin runtime: flat plugin list, per-hook implementer
929
+ * lists, envelope wire contracts by control kind. Set BEFORE the walk so
930
+ * the walk can dispatch scope hooks for the rows it creates.
931
+ */
932
+ pluginDriver: PluginDriver;
933
+ /**
934
+ * Each plugin's state container, keyed by its `PluginKey` identity
935
+ * (created by the plugin's `build`). Read through the exported keys
936
+ * (`envelopesKey.get(form, store)`), never directly.
937
+ */
938
+ pluginState: Map<PluginKey<unknown>, unknown>;
939
+ /**
940
+ * The validation mode of the form.
941
+ */
942
+ validate: ValidationMode;
943
+ /**
944
+ * The revalidation mode of the form.
945
+ */
946
+ revalidate: Exclude<ValidationMode, "initial">;
947
+ /**
948
+ * The number of active validators (kept as a counter so a future async
949
+ * validator cannot flicker `isValidating`).
950
+ */
951
+ validators: number;
952
+ /**
953
+ * The form element (react adapter only; unset on the server).
954
+ */
955
+ element?: HTMLFormElement | undefined;
956
+ /**
957
+ * Off-form values used to fill `undefined` in formula scope resolution.
958
+ * Settable: `applyBaseline` updates it and dependents re-resolve.
959
+ */
960
+ offFormValues: Signal<Record<string, unknown>>;
961
+ /**
962
+ * The submitting state of the form.
963
+ */
964
+ isSubmitting: Signal<boolean>;
965
+ /**
966
+ * The submitted state of the form.
967
+ */
968
+ isSubmitted: Signal<boolean>;
969
+ /**
970
+ * The validating state of the form.
971
+ */
972
+ isValidating: Signal<boolean>;
973
+ /**
974
+ * The cached form-level aggregates (see `FormAggregates`).
975
+ */
976
+ aggregates: FormAggregates;
977
+ }
978
+ //#endregion
979
+ //#region src/methods/form-ref.d.ts
980
+ /**
981
+ * What every method accepts as its form argument: the internal form store
982
+ * itself, or any wrapper exposing it as `internal` (the react adapter's
983
+ * public `FormStore`). App code passes the wrapper; core and tests pass the
984
+ * internal store directly.
985
+ */
986
+ type FormRef = InternalFormStore | {
987
+ readonly internal: InternalFormStore;
988
+ };
989
+ /**
990
+ * Unwraps a `FormRef` to the internal form store.
991
+ */
992
+ declare function internalOf(form: FormRef): InternalFormStore;
993
+ //#endregion
994
+ export { VisibleWhen as A, createSignal as B, FieldErrors as C, InternalFieldStore as D, InternalBaseStore as E, ReadonlySignal as F, JsonSchema as G, getListener as H, Signal as I, Path as K, Tracker as L, ControlKind as M, inferControl as N, InternalObjectStore as O, Listener as P, batch as R, FieldElement as S, InternalArrayStore as T, untrack as U, createTracker as V, withListener as W, WireContract as _, InternalFormStore as a, PluginKey as b, PluginDriver as c, hasPluginDirtyField as d, pluginsDirty as f, PluginsInput as g, PluginCtx as h, FormValidator as i, VisibleWhenOp as j, InternalValueStore as k, encodeFieldValue as l, JsonischPlugin as m, internalOf as n, ValidationIssue as o, unwrapLeafInput as p, PathSegment as q, FormConfig as r, ValidationMode as s, FormRef as t, fieldPluginDirty as u, WireEnvelope as v, FieldKind as w, ContainerInput as x, FieldSlotKey as y, computed as z };