@signaltree/schema 9.3.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/README.md ADDED
@@ -0,0 +1,218 @@
1
+ # @signaltree/schema
2
+
3
+ Schema-driven validation for SignalTree. StandardSchema-compatible, async-first, observe-only.
4
+
5
+ ```ts
6
+ import { signalTree } from '@signaltree/core';
7
+ import { schemas } from '@signaltree/schema';
8
+ import { z } from 'zod';
9
+
10
+ const tree = signalTree({ user: { email: '', age: 0 } }).with(
11
+ schemas({
12
+ schemas: {
13
+ 'user.email': z.string().email(),
14
+ 'user.age': z.number().int().min(0),
15
+ },
16
+ }),
17
+ );
18
+
19
+ tree.$.user.email.set('not-an-email');
20
+ tree.schemas.errorsAt('user.email')(); // 'Invalid email'
21
+ tree.schemas.isValid(); // false
22
+ ```
23
+
24
+ ## Why this exists
25
+
26
+ - **One registry.** Register your schemas in one place; read errors per path or in aggregate. No more drift between form validation, server-action checks, and ad-hoc Zod calls.
27
+ - **StandardSchema interop.** Works with Zod, Valibot, ArkType, Effect Schema — anything that implements [`StandardSchemaV1`](https://github.com/standard-schema/standard-schema).
28
+ - **Async-first.** Schemas can return Promises. The write-sequence guard drops stale verdicts when a newer write supersedes an in-flight async run.
29
+ - **Observe-only.** The enhancer never blocks writes. Verdicts populate signals; that's it. (See [Why no reject mode](#why-no-reject-mode) below.)
30
+
31
+ ## What it does NOT do
32
+
33
+ - **Does not block writes.** This is intentional — see [§Why no reject mode](#why-no-reject-mode).
34
+ - **Does not duplicate `@signaltree/guardrails`.** Guardrails is about performance and anti-patterns; schema is about data-shape conformance. Different jobs.
35
+ - **Does not yet integrate with Angular Signal Forms.** Signal Forms hasn't shipped in stable Angular yet. See [spike result](../ng-forms/spike/signal-form-bridge-spike.md). Wire `errorsAt(path)` into your template manually for now.
36
+
37
+ ## Install
38
+
39
+ ```sh
40
+ pnpm add @signaltree/schema @signaltree/core
41
+ # plus your schema library:
42
+ pnpm add zod # or valibot, arktype, etc.
43
+ ```
44
+
45
+ ## API
46
+
47
+ ### `schemas(config)`
48
+
49
+ Returns an enhancer. Apply via `.with(schemas({...}))`.
50
+
51
+ ```ts
52
+ interface SchemaConfig {
53
+ schemas: Record<SchemaPath, StandardSchemaV1>;
54
+ mode?: 'accept' | 'warn'; // default 'accept'
55
+ validateOnAttach?: boolean; // default true
56
+ suppressIntents?: ReadonlyArray<NonNullable<UpdateMetadata['intent']>>;
57
+ suppressSources?: ReadonlyArray<NonNullable<UpdateMetadata['source']>>;
58
+ onError?: (path: string, message: string) => void;
59
+ formatIssue?: (issue: StandardSchemaV1.Issue, path: string) => string;
60
+ }
61
+ ```
62
+
63
+ ### `tree.schemas.*` (after `.with(schemas({...}))`)
64
+
65
+ | Member | Type | Purpose |
66
+ |---|---|---|
67
+ | `errors` | `Signal<Record<path, string \| null>>` | Path → last-settled error message (or null) |
68
+ | `errorList` | `Signal<readonly string[]>` | Flat list of current error messages |
69
+ | `isValid` | `Signal<boolean>` | True iff every path's last-settled verdict is valid. **O(1) per read.** |
70
+ | `pending` | `Signal<boolean>` | True iff any path has an in-flight async run |
71
+ | `pendingPaths` | `Signal<readonly string[]>` | Paths with in-flight async runs |
72
+ | `errorsAt(path)` | `Signal<string \| null>` | Memoized per-path error signal |
73
+ | `isValidAt(path)` | `Signal<boolean>` | Memoized per-path validity |
74
+ | `isPendingAt(path)` | `Signal<boolean>` | Memoized per-path pending state |
75
+ | `validate()` | `Promise<boolean>` | Re-run all schemas, resolve to current `isValid()` |
76
+ | `validatePath(path)` | `Promise<boolean>` | Re-run schemas for one path |
77
+ | `compact()` | `void` | Manual GC — evict bound paths that no longer resolve |
78
+ | `boundPaths` | `Signal<readonly string[]>` | All currently-bound leaf paths (reactive) |
79
+
80
+ ## Entity collections — register at fields, not the collection root
81
+
82
+ Use wildcard schemas (`users.*.email`) to validate **individual entity fields**. Do NOT register a schema at the collection root itself (`users`):
83
+
84
+ ```ts
85
+ // ✅ CORRECT — wildcard schemas validate each entity's fields
86
+ schemas({
87
+ schemas: {
88
+ 'users.*.email': z.string().email(),
89
+ 'users.*.age': z.number().int().min(0),
90
+ },
91
+ });
92
+
93
+ // ❌ AVOID — registering at the collection root receives the entityMap's
94
+ // full marker value (an object with `all`/`ids`/`entities` internals),
95
+ // not an array of users. Your Zod array schema will fail.
96
+ schemas({
97
+ schemas: {
98
+ users: z.array(userSchema), // gets the entityMap value, not the user array
99
+ },
100
+ });
101
+ ```
102
+
103
+ Entity collections (markers like `entityMap()`) are normalized state, not arrays. They're meant to be queried via `.all()`, `.byId(id)`, `.where(...)`. Individual fields within entities are the validation surface.
104
+
105
+ ## Wildcard paths
106
+
107
+ Use `*` segments to match entity collections:
108
+
109
+ ```ts
110
+ schemas({
111
+ schemas: {
112
+ 'user.email': z.string().email(), // specific leaf
113
+ 'users.*.email': z.string().email(), // wildcard — every users entity
114
+ 'orders.*.items.*.qty': z.number().int(), // nested wildcards
115
+ 'profile': profileSchema, // ancestor schema (whole subtree)
116
+ },
117
+ });
118
+ ```
119
+
120
+ **Precedence** (D4 in the architecture plan):
121
+ - **Specific > wildcard > ancestor.** A schema at `users.42.email` always wins over `users.*.email`, which always wins over a `users` ancestor.
122
+ - Each leaf has exactly one owner. The owner is chosen at first-match time and cached.
123
+
124
+ ## Ancestor schemas
125
+
126
+ A schema registered at a non-leaf path (e.g., `user`) validates the whole subtree at that path. The schema runs against a fresh snapshot every time a covered leaf is written. Issues are distributed to the leaves they reference via `issue.path`.
127
+
128
+ ```ts
129
+ schemas({
130
+ schemas: {
131
+ user: z.object({
132
+ email: z.string().email(),
133
+ age: z.number().int().min(0),
134
+ }),
135
+ },
136
+ });
137
+
138
+ tree.$.user.email.set('bad');
139
+ tree.schemas.errorsAt('user.email')(); // 'Invalid email'
140
+ tree.schemas.errorsAt('user.age')(); // depends on current age value
141
+ ```
142
+
143
+ Issues from ancestor schemas use the leaf's nearest-match path via `issueToLeafPath`. The per-leaf staleness guard ensures slow ancestor runs can't clobber faster leaf writes that happened mid-flight.
144
+
145
+ ## Async semantics
146
+
147
+ Async schemas (Valibot, custom uniqueness checks, etc.) return Promises. Behavior:
148
+
149
+ - **Pending state:** while a schema is in flight, `pending()` is true, `pendingPaths()` includes the path, `isPendingAt(path)()` is true.
150
+ - **Last-settled verdict:** `errorsAt`, `isValid`, etc. read the **last settled** verdict — they don't flicker to null during in-flight runs.
151
+ - **Write-sequence guard:** if a newer write happens while an older schema run is in flight, the older verdict is dropped on settle (orphaned). The promise still resolves to completion (we can't abort it); only its verdict is discarded.
152
+
153
+ ### Debounce `validate()` for I/O-bound schemas
154
+
155
+ `validate()` called repeatedly during typing piles up orphaned network requests that all run to completion before being discarded. If your schemas hit a server, debounce the caller:
156
+
157
+ ```ts
158
+ const debouncedValidate = debounce(() => tree.schemas.validate(), 300);
159
+ ```
160
+
161
+ ## Suppression — skip validation for replays
162
+
163
+ By default, validation runs on every write — including time-travel replays, hydration, and migrations. To suppress for specific intents/sources:
164
+
165
+ ```ts
166
+ schemas({
167
+ schemas: { ... },
168
+ suppressIntents: ['hydrate', 'migration'],
169
+ suppressSources: ['time-travel'],
170
+ });
171
+ ```
172
+
173
+ The suppression reads the ambient write-context set via `withWriteContext()` from `@signaltree/core`. Devtools time-travel and the time-travel enhancer already wrap their replays in `withWriteContext({ source: 'time-travel' })`.
174
+
175
+ **Do not suppress `source: 'serialization'`** — deserialize is the canonical ingest case validation exists for.
176
+
177
+ ## Compaction (`compact()`)
178
+
179
+ The registry's `boundPathsSet` is bounded by **distinct leaf paths ever written that matched a schema**, not by current entity count. A long-lived `users.*.email` over a session that churns 10,000 user rows will retain 10,000 `PathState` entries.
180
+
181
+ Call `tree.schemas.compact()` periodically (e.g., on tab visibility change, or after entity-bulk-removal) to evict bound paths that no longer resolve in the tree.
182
+
183
+ ```ts
184
+ // After removing entities:
185
+ tree.schemas.compact();
186
+ ```
187
+
188
+ ## Why no reject mode
189
+
190
+ Some readers reach for `mode: 'reject'`. We don't offer it. Reasons:
191
+
192
+ 1. **Async schemas can't gate synchronously.** A Promise-returning schema means the write has already notified subscribers before the verdict arrives. "Reject" would mean silently rolling back state subscribers already saw.
193
+ 2. **Sync schemas don't save it either.** The enhancer observes writes via `interceptLeafSignals` — *after* the underlying signal has updated. There's no pre-write hook.
194
+ 3. **It's not a validation problem.** The right place to gate input is the form layer (Signal Forms' field validators, ReactiveForms' validators). The store edge is a reporter, not a gate.
195
+
196
+ If you genuinely need to refuse a write: gate it in the form, in a guardrails rule, or in a wrapper around your write site.
197
+
198
+ ## Bundle size
199
+
200
+ ~4.3 KB gzipped. Tree-shakable. Angular is a peer dependency.
201
+
202
+ ## Migration: `UpdateMetadata` lifted to core
203
+
204
+ In v9.3, `UpdateMetadata` was lifted from `@signaltree/guardrails` to `@signaltree/core`. If you imported it from guardrails, update the import:
205
+
206
+ ```ts
207
+ // Before (still works as deprecated re-export)
208
+ import type { UpdateMetadata } from '@signaltree/guardrails';
209
+
210
+ // After
211
+ import type { UpdateMetadata } from '@signaltree/core';
212
+ ```
213
+
214
+ ## See also
215
+
216
+ - [Architecture plan](../../docs/architecture/validation-enhancer-plan.md) — design decisions and trade-offs
217
+ - [`@signaltree/core`](../core) — base library
218
+ - [`@signaltree/guardrails`](../guardrails) — performance and anti-pattern detection (different concern)
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { schemas } from './lib/schema.js';
@@ -0,0 +1,29 @@
1
+ import { computed } from '@angular/core';
2
+
3
+ function createAggregates(registry) {
4
+ const isValid = computed(() => registry.invalidCount() === 0);
5
+ const errors = computed(() => {
6
+ const out = {};
7
+ for (const [path, state] of registry.pathStates) {
8
+ out[path] = state.errorSignal();
9
+ }
10
+ return out;
11
+ });
12
+ const errorList = computed(() => {
13
+ const map = errors();
14
+ const list = [];
15
+ for (const v of Object.values(map)) {
16
+ if (v !== null) list.push(v);
17
+ }
18
+ return list;
19
+ });
20
+ const pending = computed(() => registry.pendingPathsSignal().length > 0);
21
+ return {
22
+ errors,
23
+ errorList,
24
+ isValid,
25
+ pending
26
+ };
27
+ }
28
+
29
+ export { createAggregates };
@@ -0,0 +1,111 @@
1
+ import { ensurePathState, addPendingPath, removePendingPath } from './state.js';
2
+ import { readTreeAtPath, collectOwnedLeaves } from './matcher.js';
3
+ import { defaultFormatIssue, issueToLeafPath } from './issue-mapper.js';
4
+ import { applyLeafVerdict } from './leaf-handler.js';
5
+
6
+ function dispatchAncestorRun(registry, treeRoot, entry, ancestorPath) {
7
+ const ancestorValue = readTreeAtPath(treeRoot, ancestorPath);
8
+ const ownedLeaves = new Set(collectOwnedLeaves(registry, entry, ancestorPath, ancestorValue));
9
+ const capturedVersions = new Map();
10
+ for (const leaf of ownedLeaves) {
11
+ capturedVersions.set(leaf, ensurePathState(registry, leaf).version);
12
+ }
13
+ const runId = ++registry.nextAncestorRunId;
14
+ const record = {
15
+ runId,
16
+ capturedVersions,
17
+ ownedLeaves
18
+ };
19
+ registry.activeAncestorRuns.set(ancestorPath, record);
20
+ let result;
21
+ try {
22
+ result = entry.schema['~standard'].validate(ancestorValue);
23
+ } catch (err) {
24
+ applyAncestorVerdict(registry, entry, ancestorPath, ownedLeaves, capturedVersions, {
25
+ issues: [{
26
+ message: runtimeErrorMessage(err)
27
+ }]
28
+ }, true);
29
+ registry.activeAncestorRuns.delete(ancestorPath);
30
+ return;
31
+ }
32
+ if (!(result instanceof Promise)) {
33
+ applyAncestorVerdict(registry, entry, ancestorPath, ownedLeaves, capturedVersions, result, false);
34
+ registry.activeAncestorRuns.delete(ancestorPath);
35
+ return;
36
+ }
37
+ for (const leaf of ownedLeaves) {
38
+ const state = ensurePathState(registry, leaf);
39
+ state.pendingSignal.set(true);
40
+ addPendingPath(registry, leaf);
41
+ }
42
+ return result.then(settled => {
43
+ applyAncestorVerdict(registry, entry, ancestorPath, ownedLeaves, capturedVersions, settled, false);
44
+ for (const leaf of ownedLeaves) {
45
+ const state = ensurePathState(registry, leaf);
46
+ if (state.inFlightVersion === null) {
47
+ state.pendingSignal.set(false);
48
+ removePendingPath(registry, leaf);
49
+ }
50
+ }
51
+ if (registry.activeAncestorRuns.get(ancestorPath)?.runId === runId) {
52
+ registry.activeAncestorRuns.delete(ancestorPath);
53
+ }
54
+ }, err => {
55
+ applyAncestorVerdict(registry, entry, ancestorPath, ownedLeaves, capturedVersions, {
56
+ issues: [{
57
+ message: runtimeErrorMessage(err)
58
+ }]
59
+ }, true);
60
+ for (const leaf of ownedLeaves) {
61
+ const state = ensurePathState(registry, leaf);
62
+ if (state.inFlightVersion === null) {
63
+ state.pendingSignal.set(false);
64
+ removePendingPath(registry, leaf);
65
+ }
66
+ }
67
+ if (registry.activeAncestorRuns.get(ancestorPath)?.runId === runId) {
68
+ registry.activeAncestorRuns.delete(ancestorPath);
69
+ }
70
+ });
71
+ }
72
+ function applyAncestorVerdict(registry, _entry, ancestorPath, ownedLeaves, capturedVersions, result, fromRuntimeError) {
73
+ const formatIssue = registry.config.formatIssue ?? defaultFormatIssue;
74
+ if ('issues' in result && result.issues && result.issues.length > 0) {
75
+ const reported = new Set();
76
+ for (const issue of result.issues) {
77
+ const leafPath = issueToLeafPath(ancestorPath, issue);
78
+ if (!ownedLeaves.has(leafPath)) {
79
+ if (fromRuntimeError) {
80
+ for (const leaf of ownedLeaves) {
81
+ staleSafeApply(registry, leaf, capturedVersions, formatIssue(issue, leaf));
82
+ reported.add(leaf);
83
+ }
84
+ }
85
+ continue;
86
+ }
87
+ staleSafeApply(registry, leafPath, capturedVersions, formatIssue(issue, leafPath));
88
+ reported.add(leafPath);
89
+ }
90
+ for (const leaf of ownedLeaves) {
91
+ if (reported.has(leaf)) continue;
92
+ staleSafeApply(registry, leaf, capturedVersions, null);
93
+ }
94
+ return;
95
+ }
96
+ for (const leaf of ownedLeaves) {
97
+ staleSafeApply(registry, leaf, capturedVersions, null);
98
+ }
99
+ }
100
+ function staleSafeApply(registry, leafPath, capturedVersions, msg) {
101
+ const state = ensurePathState(registry, leafPath);
102
+ const captured = capturedVersions.get(leafPath);
103
+ if (captured === undefined) return;
104
+ if (state.version !== captured) return;
105
+ applyLeafVerdict(registry, state, leafPath, msg);
106
+ }
107
+ function runtimeErrorMessage(err) {
108
+ return `validation runtime error: ${String(err instanceof Error ? err.message : err)}`;
109
+ }
110
+
111
+ export { dispatchAncestorRun };
@@ -0,0 +1,44 @@
1
+ import { removeBoundPath, removePendingPath } from './state.js';
2
+
3
+ function compact(registry, treeRoot) {
4
+ const toEvict = [];
5
+ for (const path of registry.boundPathsSet) {
6
+ if (!pathExists(treeRoot, path)) {
7
+ toEvict.push(path);
8
+ }
9
+ }
10
+ for (const path of toEvict) {
11
+ evictPath(registry, path);
12
+ }
13
+ }
14
+ function pathExists(treeRoot, path) {
15
+ if (path === '') return treeRoot !== undefined && treeRoot !== null;
16
+ const segs = path.split('.');
17
+ let cur = treeRoot;
18
+ for (const seg of segs) {
19
+ if (cur === null || cur === undefined) return false;
20
+ if (typeof cur !== 'object' && typeof cur !== 'function') return false;
21
+ if (!(seg in cur)) return false;
22
+ cur = cur[seg];
23
+ }
24
+ return true;
25
+ }
26
+ function evictPath(registry, path) {
27
+ const state = registry.pathStates.get(path);
28
+ if (state) {
29
+ if (state.lastSettledError !== null) {
30
+ registry.invalidCount.update(c => c - 1);
31
+ }
32
+ state.errorSignal.set(null);
33
+ state.pendingSignal.set(false);
34
+ }
35
+ registry.pathStates.delete(path);
36
+ registry.errorsAtCache.delete(path);
37
+ registry.isValidAtCache.delete(path);
38
+ registry.isPendingAtCache.delete(path);
39
+ registry.leafOwner.delete(path);
40
+ removeBoundPath(registry, path);
41
+ removePendingPath(registry, path);
42
+ }
43
+
44
+ export { compact };
@@ -0,0 +1,25 @@
1
+ function issueToLeafPath(rootPath, issue) {
2
+ const path = issue.path;
3
+ if (!path || path.length === 0) return rootPath;
4
+ const segs = [];
5
+ for (const p of path) {
6
+ if (typeof p === 'object' && p !== null && 'key' in p) {
7
+ segs.push(String(p.key));
8
+ } else {
9
+ segs.push(String(p));
10
+ }
11
+ }
12
+ if (segs.length === 0) return rootPath;
13
+ return rootPath ? `${rootPath}.${segs.join('.')}` : segs.join('.');
14
+ }
15
+ function defaultFormatIssue(issue) {
16
+ return issue.message;
17
+ }
18
+ function resultToMessage(result, path, formatIssue) {
19
+ if (!('issues' in result) || !result.issues || result.issues.length === 0) {
20
+ return null;
21
+ }
22
+ return formatIssue(result.issues[0], path);
23
+ }
24
+
25
+ export { defaultFormatIssue, issueToLeafPath, resultToMessage };
@@ -0,0 +1,79 @@
1
+ import { ensurePathState, addPendingPath, removePendingPath } from './state.js';
2
+ import { matchLeaf, matchAncestors } from './matcher.js';
3
+ import { resultToMessage, defaultFormatIssue } from './issue-mapper.js';
4
+ import { dispatchAncestorRun } from './ancestor-handler.js';
5
+
6
+ function routeWrite(registry, treeRoot, path, next, meta) {
7
+ if (meta?.intent && registry.config.suppressIntents?.includes(meta.intent)) {
8
+ return;
9
+ }
10
+ if (meta?.source && registry.config.suppressSources?.includes(meta.source)) {
11
+ return;
12
+ }
13
+ if (meta?.suppressGuardrails) return;
14
+ const leafEntry = matchLeaf(registry, path);
15
+ if (leafEntry && isLeafLengthMatch(leafEntry, path)) {
16
+ dispatchLeafRun(registry, leafEntry, path, next);
17
+ }
18
+ for (const {
19
+ entry,
20
+ ancestorPath
21
+ } of matchAncestors(registry, path)) {
22
+ void dispatchAncestorRun(registry, treeRoot, entry, ancestorPath);
23
+ }
24
+ }
25
+ function isLeafLengthMatch(entry, leafPath) {
26
+ const leafLen = leafPath === '' ? 0 : leafPath.split('.').length;
27
+ return entry.segments.length === leafLen;
28
+ }
29
+ function dispatchLeafRun(registry, entry, path, next) {
30
+ const state = ensurePathState(registry, path);
31
+ const myVersion = ++state.version;
32
+ let result;
33
+ try {
34
+ result = entry.schema['~standard'].validate(next);
35
+ } catch (err) {
36
+ applyLeafVerdict(registry, state, path, runtimeErrorMessage(err));
37
+ return;
38
+ }
39
+ if (!(result instanceof Promise)) {
40
+ applyLeafVerdict(registry, state, path, resultToMessage(result, path, registry.config.formatIssue ?? defaultFormatIssue));
41
+ return;
42
+ }
43
+ state.inFlightVersion = myVersion;
44
+ state.pendingSignal.set(true);
45
+ addPendingPath(registry, path);
46
+ result.then(settled => {
47
+ if (state.inFlightVersion !== myVersion) return;
48
+ state.inFlightVersion = null;
49
+ applyLeafVerdict(registry, state, path, resultToMessage(settled, path, registry.config.formatIssue ?? defaultFormatIssue));
50
+ state.pendingSignal.set(false);
51
+ removePendingPath(registry, path);
52
+ }, err => {
53
+ if (state.inFlightVersion !== myVersion) return;
54
+ state.inFlightVersion = null;
55
+ applyLeafVerdict(registry, state, path, runtimeErrorMessage(err));
56
+ state.pendingSignal.set(false);
57
+ removePendingPath(registry, path);
58
+ });
59
+ }
60
+ function applyLeafVerdict(registry, state, path, msg) {
61
+ const wasInvalid = state.lastSettledError !== null;
62
+ const nowInvalid = msg !== null;
63
+ if (wasInvalid !== nowInvalid) {
64
+ registry.invalidCount.update(c => c + (nowInvalid ? 1 : -1));
65
+ }
66
+ state.lastSettledError = msg;
67
+ state.errorSignal.set(msg);
68
+ if (msg && registry.config.mode === 'warn') {
69
+ (registry.config.onError ?? defaultWarn)(path, msg);
70
+ }
71
+ }
72
+ function runtimeErrorMessage(err) {
73
+ return `validation runtime error: ${String(err instanceof Error ? err.message : err)}`;
74
+ }
75
+ function defaultWarn(path, message) {
76
+ console.warn(`[validation] ${path}: ${message}`);
77
+ }
78
+
79
+ export { applyLeafVerdict, dispatchLeafRun, routeWrite };
@@ -0,0 +1,141 @@
1
+ import { isSignal } from '@angular/core';
2
+ import { WILDCARD, addBoundPath } from './state.js';
3
+
4
+ function compilePattern(pattern) {
5
+ return pattern.split('.').map(seg => seg === '*' ? WILDCARD : seg);
6
+ }
7
+ function matchSpecificity(pattern, segs) {
8
+ if (pattern.length > segs.length) return -1;
9
+ let literalCount = 0;
10
+ for (let i = 0; i < pattern.length; i++) {
11
+ const ps = pattern[i];
12
+ if (ps === WILDCARD) continue;
13
+ if (ps !== segs[i]) return -1;
14
+ literalCount++;
15
+ }
16
+ const exactLength = pattern.length === segs.length;
17
+ return literalCount * 2 + (exactLength ? 10_000 : 0);
18
+ }
19
+ function isStrictPrefixMatch(pattern, segs) {
20
+ if (pattern.length >= segs.length) return false;
21
+ for (let i = 0; i < pattern.length; i++) {
22
+ const ps = pattern[i];
23
+ if (ps === WILDCARD) continue;
24
+ if (ps !== segs[i]) return false;
25
+ }
26
+ return true;
27
+ }
28
+ function matchLeaf(registry, leafPath) {
29
+ const cached = registry.leafOwner.get(leafPath);
30
+ if (cached) return cached;
31
+ const segs = leafPath.split('.');
32
+ let best;
33
+ let bestScore = -1;
34
+ for (const entry of registry.entries) {
35
+ const score = matchSpecificity(entry.segments, segs);
36
+ if (score > bestScore) {
37
+ best = entry;
38
+ bestScore = score;
39
+ }
40
+ }
41
+ if (best) {
42
+ registry.leafOwner.set(leafPath, best);
43
+ addBoundPath(registry, leafPath);
44
+ }
45
+ return best;
46
+ }
47
+ function matchAncestors(registry, leafPath) {
48
+ const segs = leafPath.split('.');
49
+ const matches = [];
50
+ for (const entry of registry.entries) {
51
+ if (!isStrictPrefixMatch(entry.segments, segs)) continue;
52
+ const ancestorPath = segs.slice(0, entry.segments.length).join('.');
53
+ matches.push({
54
+ entry,
55
+ ancestorPath
56
+ });
57
+ }
58
+ return matches;
59
+ }
60
+ function enumerateLeafPaths(value, rootPath) {
61
+ const out = [];
62
+ walk(value, rootPath, out);
63
+ return out;
64
+ }
65
+ function walk(value, prefix, out) {
66
+ if (value === null || value === undefined) {
67
+ out.push(prefix);
68
+ return;
69
+ }
70
+ if (typeof value !== 'object') {
71
+ out.push(prefix);
72
+ return;
73
+ }
74
+ if (Array.isArray(value) || value instanceof Date || value instanceof Map || value instanceof Set) {
75
+ out.push(prefix);
76
+ return;
77
+ }
78
+ const keys = Object.keys(value);
79
+ if (keys.length === 0) {
80
+ out.push(prefix);
81
+ return;
82
+ }
83
+ for (const k of keys) {
84
+ const child = value[k];
85
+ walk(child, prefix ? `${prefix}.${k}` : k, out);
86
+ }
87
+ }
88
+ function isLeafSignal(value) {
89
+ if (typeof value !== 'function') return false;
90
+ if (!isSignal(value)) return false;
91
+ const v = value;
92
+ return typeof v.set === 'function' && typeof v.update === 'function';
93
+ }
94
+ function snapshotTreeNode(node) {
95
+ if (node === null || node === undefined) return node;
96
+ if (isLeafSignal(node)) {
97
+ try {
98
+ return node();
99
+ } catch {
100
+ return undefined;
101
+ }
102
+ }
103
+ if (typeof node !== 'object' && typeof node !== 'function') return node;
104
+ if (Array.isArray(node) || node instanceof Date || node instanceof Map || node instanceof Set) {
105
+ return node;
106
+ }
107
+ const out = {};
108
+ let keys;
109
+ try {
110
+ keys = Object.keys(node);
111
+ } catch {
112
+ return node;
113
+ }
114
+ for (const key of keys) {
115
+ const child = node[key];
116
+ out[key] = snapshotTreeNode(child);
117
+ }
118
+ return out;
119
+ }
120
+ function readTreeAtPath(treeRoot, path) {
121
+ if (path === '') return snapshotTreeNode(treeRoot);
122
+ const segs = path.split('.');
123
+ let cur = treeRoot;
124
+ for (const seg of segs) {
125
+ if (cur === null || cur === undefined) return undefined;
126
+ if (typeof cur !== 'object' && typeof cur !== 'function') return undefined;
127
+ cur = cur[seg];
128
+ }
129
+ return snapshotTreeNode(cur);
130
+ }
131
+ function collectOwnedLeaves(registry, ancestorEntry, ancestorPath, ancestorValue) {
132
+ const allLeaves = enumerateLeafPaths(ancestorValue, ancestorPath);
133
+ const owned = [];
134
+ for (const leaf of allLeaves) {
135
+ const owner = matchLeaf(registry, leaf);
136
+ if (owner === ancestorEntry) owned.push(leaf);
137
+ }
138
+ return owned;
139
+ }
140
+
141
+ export { collectOwnedLeaves, compilePattern, enumerateLeafPaths, matchAncestors, matchLeaf, matchSpecificity, readTreeAtPath, snapshotTreeNode };
@@ -0,0 +1,26 @@
1
+ import { computed } from '@angular/core';
2
+ import { ensurePathState } from './state.js';
3
+
4
+ function errorsAt(registry, path) {
5
+ const cached = registry.errorsAtCache.get(path);
6
+ if (cached) return cached;
7
+ const sig = computed(() => ensurePathState(registry, path).errorSignal());
8
+ registry.errorsAtCache.set(path, sig);
9
+ return sig;
10
+ }
11
+ function isValidAt(registry, path) {
12
+ const cached = registry.isValidAtCache.get(path);
13
+ if (cached) return cached;
14
+ const sig = computed(() => ensurePathState(registry, path).errorSignal() === null);
15
+ registry.isValidAtCache.set(path, sig);
16
+ return sig;
17
+ }
18
+ function isPendingAt(registry, path) {
19
+ const cached = registry.isPendingAtCache.get(path);
20
+ if (cached) return cached;
21
+ const sig = computed(() => ensurePathState(registry, path).pendingSignal());
22
+ registry.isPendingAtCache.set(path, sig);
23
+ return sig;
24
+ }
25
+
26
+ export { errorsAt, isPendingAt, isValidAt };
@@ -0,0 +1,42 @@
1
+ import { signal } from '@angular/core';
2
+
3
+ const WILDCARD = Symbol('validation/wildcard');
4
+ function createPathState() {
5
+ return {
6
+ version: 0,
7
+ lastSettledError: null,
8
+ inFlightVersion: null,
9
+ errorSignal: signal(null),
10
+ pendingSignal: signal(false)
11
+ };
12
+ }
13
+ function ensurePathState(registry, path) {
14
+ let state = registry.pathStates.get(path);
15
+ if (!state) {
16
+ state = createPathState();
17
+ registry.pathStates.set(path, state);
18
+ }
19
+ return state;
20
+ }
21
+ function addBoundPath(registry, path) {
22
+ if (registry.boundPathsSet.has(path)) return;
23
+ registry.boundPathsSet.add(path);
24
+ registry.boundPathsSignal.set(Array.from(registry.boundPathsSet));
25
+ }
26
+ function removeBoundPath(registry, path) {
27
+ if (!registry.boundPathsSet.has(path)) return;
28
+ registry.boundPathsSet.delete(path);
29
+ registry.boundPathsSignal.set(Array.from(registry.boundPathsSet));
30
+ }
31
+ function addPendingPath(registry, path) {
32
+ if (registry.pendingPathsSet.has(path)) return;
33
+ registry.pendingPathsSet.add(path);
34
+ registry.pendingPathsSignal.set(Array.from(registry.pendingPathsSet));
35
+ }
36
+ function removePendingPath(registry, path) {
37
+ if (!registry.pendingPathsSet.has(path)) return;
38
+ registry.pendingPathsSet.delete(path);
39
+ registry.pendingPathsSignal.set(Array.from(registry.pendingPathsSet));
40
+ }
41
+
42
+ export { WILDCARD, addBoundPath, addPendingPath, createPathState, ensurePathState, removeBoundPath, removePendingPath };
@@ -0,0 +1,199 @@
1
+ import { signal } from '@angular/core';
2
+ import { interceptLeafSignals } from '@signaltree/core';
3
+ import { WILDCARD, ensurePathState, addPendingPath, removePendingPath } from './internals/state.js';
4
+ import { compilePattern, readTreeAtPath, enumerateLeafPaths, matchLeaf, matchAncestors } from './internals/matcher.js';
5
+ import { routeWrite, dispatchLeafRun, applyLeafVerdict } from './internals/leaf-handler.js';
6
+ import { dispatchAncestorRun } from './internals/ancestor-handler.js';
7
+ import { isPendingAt, isValidAt, errorsAt } from './internals/signal-cache.js';
8
+ import { createAggregates } from './internals/aggregates.js';
9
+ import { compact } from './internals/compact.js';
10
+ import { defaultFormatIssue, resultToMessage } from './internals/issue-mapper.js';
11
+
12
+ function schemas(config) {
13
+ return function (tree) {
14
+ const entries = compileEntries(config);
15
+ const registry = {
16
+ entries,
17
+ leafOwner: new Map(),
18
+ pathStates: new Map(),
19
+ errorsAtCache: new Map(),
20
+ isValidAtCache: new Map(),
21
+ isPendingAtCache: new Map(),
22
+ invalidCount: signal(0),
23
+ activeAncestorRuns: new Map(),
24
+ nextAncestorRunId: 0,
25
+ pendingPathsSignal: signal([]),
26
+ boundPathsSignal: signal([]),
27
+ boundPathsSet: new Set(),
28
+ pendingPathsSet: new Set(),
29
+ config
30
+ };
31
+ const aggregates = createAggregates(registry);
32
+ const treeRoot = tree.$;
33
+ const restoreInterceptor = interceptLeafSignals(treeRoot, (path, next, _prev, meta) => {
34
+ routeWrite(registry, treeRoot, path, next, meta);
35
+ });
36
+ if (config.validateOnAttach !== false) {
37
+ initialValidation(registry, treeRoot);
38
+ }
39
+ const originalDestroy = tree.destroy?.bind(tree);
40
+ tree.destroy = () => {
41
+ restoreInterceptor();
42
+ if (originalDestroy) originalDestroy();
43
+ };
44
+ const methods = {
45
+ errors: aggregates.errors,
46
+ errorList: aggregates.errorList,
47
+ isValid: aggregates.isValid,
48
+ pending: aggregates.pending,
49
+ pendingPaths: registry.pendingPathsSignal,
50
+ errorsAt: path => errorsAt(registry, path),
51
+ isValidAt: path => isValidAt(registry, path),
52
+ isPendingAt: path => isPendingAt(registry, path),
53
+ validate: () => validateAll(registry, treeRoot),
54
+ validatePath: path => validateOnePath(registry, treeRoot, path),
55
+ compact: () => compact(registry, treeRoot),
56
+ schemaFor: leafPath => {
57
+ const owner = matchLeaf(registry, leafPath);
58
+ return owner?.schema;
59
+ },
60
+ boundPaths: registry.boundPathsSignal
61
+ };
62
+ tree['schemas'] = methods;
63
+ return tree;
64
+ };
65
+ }
66
+ function compileEntries(config) {
67
+ const entries = [];
68
+ for (const [pattern, schema] of Object.entries(config.schemas)) {
69
+ const segments = compilePattern(pattern);
70
+ entries.push({
71
+ pattern,
72
+ schema,
73
+ isWildcard: segments.includes(WILDCARD),
74
+ isAncestor: false,
75
+ segments
76
+ });
77
+ }
78
+ return entries;
79
+ }
80
+ function initialValidation(registry, treeRoot) {
81
+ const rootValue = readTreeAtPath(treeRoot, '');
82
+ const allLeaves = enumerateLeafPaths(rootValue, '');
83
+ const ancestorPathsToRun = new Set();
84
+ for (const leafPath of allLeaves) {
85
+ const owner = matchLeaf(registry, leafPath);
86
+ if (!owner) continue;
87
+ const leafLen = leafPath === '' ? 0 : leafPath.split('.').length;
88
+ if (owner.segments.length === leafLen) {
89
+ const value = readTreeAtPath(treeRoot, leafPath);
90
+ dispatchLeafRun(registry, owner, leafPath, value);
91
+ }
92
+ for (const {
93
+ entry,
94
+ ancestorPath
95
+ } of matchAncestors(registry, leafPath)) {
96
+ ancestorPathsToRun.add(`${entry.pattern}::${ancestorPath}`);
97
+ _scheduleAncestorRun(registry, treeRoot, entry, ancestorPath);
98
+ }
99
+ }
100
+ }
101
+ const _ancestorScheduled = new WeakMap();
102
+ function _scheduleAncestorRun(registry, treeRoot, entry, ancestorPath, _dedupHint) {
103
+ let scheduled = _ancestorScheduled.get(registry);
104
+ if (!scheduled) {
105
+ scheduled = new Set();
106
+ _ancestorScheduled.set(registry, scheduled);
107
+ }
108
+ const key = `${entry.pattern}::${ancestorPath}`;
109
+ if (scheduled.has(key)) return;
110
+ scheduled.add(key);
111
+ void dispatchAncestorRun(registry, treeRoot, entry, ancestorPath);
112
+ }
113
+ async function validateAll(registry, treeRoot) {
114
+ const promises = [];
115
+ for (const entry of registry.entries) {
116
+ if (entry.isWildcard) {
117
+ for (const path of registry.boundPathsSet) {
118
+ const owner = matchLeaf(registry, path);
119
+ if (owner === entry) {
120
+ promises.push(runOnePathForEntry(registry, treeRoot, entry, path));
121
+ }
122
+ }
123
+ continue;
124
+ }
125
+ entry.segments.length;
126
+ const literalPath = entry.segments.join('.');
127
+ promises.push(runOneLiteralEntry(registry, treeRoot, entry, literalPath));
128
+ }
129
+ await Promise.all(promises);
130
+ return registry.invalidCount() === 0;
131
+ }
132
+ async function runOnePathForEntry(registry, treeRoot, entry, leafPath) {
133
+ const leafLen = leafPath === '' ? 0 : leafPath.split('.').length;
134
+ if (entry.segments.length === leafLen) {
135
+ const value = readTreeAtPath(treeRoot, leafPath);
136
+ await runLeafAwait(registry, entry, leafPath, value);
137
+ } else {
138
+ const ancestorPath = leafPath.split('.').slice(0, entry.segments.length).join('.');
139
+ await Promise.resolve(dispatchAncestorRun(registry, treeRoot, entry, ancestorPath));
140
+ }
141
+ }
142
+ async function runOneLiteralEntry(registry, treeRoot, entry, literalPath, patternSegs) {
143
+ const value = readTreeAtPath(treeRoot, literalPath);
144
+ if (isAncestorTarget(value)) {
145
+ await Promise.resolve(dispatchAncestorRun(registry, treeRoot, entry, literalPath));
146
+ return;
147
+ }
148
+ await runLeafAwait(registry, entry, literalPath, value);
149
+ }
150
+ function isAncestorTarget(value) {
151
+ if (value === null || value === undefined) return false;
152
+ if (typeof value !== 'object') return false;
153
+ if (Array.isArray(value)) return false;
154
+ if (value instanceof Date || value instanceof Map || value instanceof Set) return false;
155
+ return true;
156
+ }
157
+ function runLeafAwait(registry, entry, path, next) {
158
+ const state = ensurePathState(registry, path);
159
+ const myVersion = ++state.version;
160
+ const formatIssue = registry.config.formatIssue ?? defaultFormatIssue;
161
+ let result;
162
+ try {
163
+ result = entry.schema['~standard'].validate(next);
164
+ } catch (err) {
165
+ applyLeafVerdict(registry, state, path, runtimeErrorMessage(err));
166
+ return Promise.resolve();
167
+ }
168
+ if (!(result instanceof Promise)) {
169
+ applyLeafVerdict(registry, state, path, resultToMessage(result, path, formatIssue));
170
+ return Promise.resolve();
171
+ }
172
+ state.inFlightVersion = myVersion;
173
+ state.pendingSignal.set(true);
174
+ addPendingPath(registry, path);
175
+ return result.then(settled => {
176
+ if (state.inFlightVersion !== myVersion) return;
177
+ state.inFlightVersion = null;
178
+ applyLeafVerdict(registry, state, path, resultToMessage(settled, path, formatIssue));
179
+ state.pendingSignal.set(false);
180
+ removePendingPath(registry, path);
181
+ }, err => {
182
+ if (state.inFlightVersion !== myVersion) return;
183
+ state.inFlightVersion = null;
184
+ applyLeafVerdict(registry, state, path, runtimeErrorMessage(err));
185
+ state.pendingSignal.set(false);
186
+ removePendingPath(registry, path);
187
+ });
188
+ }
189
+ async function validateOnePath(registry, treeRoot, path) {
190
+ const owner = matchLeaf(registry, path);
191
+ if (!owner) return registry.invalidCount() === 0;
192
+ await runOnePathForEntry(registry, treeRoot, owner, path);
193
+ return registry.invalidCount() === 0;
194
+ }
195
+ function runtimeErrorMessage(err) {
196
+ return `validation runtime error: ${String(err instanceof Error ? err.message : err)}`;
197
+ }
198
+
199
+ export { schemas };
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@signaltree/schema",
3
+ "version": "9.3.0",
4
+ "description": "Schema-driven validation for SignalTree. StandardSchema-compatible, async-first, observe-only.",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "main": "./dist/index.js",
8
+ "module": "./dist/index.js",
9
+ "types": "./src/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./src/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "default": "./dist/index.js"
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "dist/**/*.js",
20
+ "src/**/*.d.ts",
21
+ "README.md",
22
+ "CHANGELOG.md"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "build": "nx build schema",
29
+ "test": "nx test schema",
30
+ "lint": "nx lint schema",
31
+ "test:watch": "nx test schema --watch",
32
+ "test:coverage": "nx test schema --coverage",
33
+ "type-check": "tsc --project tsconfig.lib.json --noEmit"
34
+ },
35
+ "peerDependencies": {
36
+ "@angular/core": "^20.0.0 || ^21.0.0",
37
+ "@signaltree/core": "^9.0.1",
38
+ "@standard-schema/spec": "^1.0.0",
39
+ "tslib": "^2.0.0"
40
+ },
41
+ "peerDependenciesMeta": {},
42
+ "devDependencies": {
43
+ "@signaltree/core": "workspace:*",
44
+ "@standard-schema/spec": "^1.0.0"
45
+ },
46
+ "keywords": [
47
+ "signaltree",
48
+ "state-management",
49
+ "schema",
50
+ "validation",
51
+ "standard-schema",
52
+ "zod",
53
+ "valibot",
54
+ "arktype",
55
+ "angular",
56
+ "signals"
57
+ ],
58
+ "author": "SignalTree Team",
59
+ "license": "MIT",
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "https://github.com/signaltree/signaltree.git",
63
+ "directory": "packages/schema"
64
+ }
65
+ }
@@ -0,0 +1,10 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
2
+ export declare function syncSchema<T = unknown>(check: (v: unknown) => string | null): StandardSchemaV1<T, T>;
3
+ export declare function asyncSchema<T = unknown>(check: (v: unknown) => Promise<string | null> | string | null, delayMs?: number): StandardSchemaV1<T, T>;
4
+ export declare function controllableSchema<T = unknown>(): {
5
+ schema: StandardSchemaV1<T, T>;
6
+ resolveLatest(msg: string | null): void;
7
+ resolveAll(msg: string | null): void;
8
+ pendingCount(): number;
9
+ };
10
+ export declare function throwingSchema(message?: string): StandardSchemaV1<unknown, unknown>;
package/src/index.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { schemas } from './lib/schema';
2
+ export type { SchemaConfig, SchemaMethods, SchemaPath, } from './lib/types';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ import { readTreeAtPath } from './matcher';
@@ -0,0 +1,3 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
2
+ export declare function defaultFormatIssue(issue: StandardSchemaV1.Issue): string;
3
+ export declare function resultToMessage(result: StandardSchemaV1.Result<unknown>, path: string, formatIssue: (issue: StandardSchemaV1.Issue, path: string) => string): string | null;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { SchemaEntry, PatternSegment, Registry } from './state';
2
+ export declare function compilePattern(pattern: string): PatternSegment[];
3
+ export declare function matchLeaf(registry: Registry, leafPath: string): SchemaEntry | undefined;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ import { WritableSignal, Signal } from '@angular/core';
2
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
3
+ import type { SchemaConfig } from '../types';
4
+ export declare const WILDCARD: unique symbol;
5
+ export type PatternSegment = string | typeof WILDCARD;
6
+ export interface PathState {
7
+ version: number;
8
+ lastSettledError: string | null;
9
+ inFlightVersion: number | null;
10
+ errorSignal: WritableSignal<string | null>;
11
+ pendingSignal: WritableSignal<boolean>;
12
+ }
13
+ export interface AncestorRunRecord {
14
+ runId: number;
15
+ capturedVersions: ReadonlyMap<string, number>;
16
+ ownedLeaves: ReadonlySet<string>;
17
+ }
18
+ export interface SchemaEntry {
19
+ pattern: string;
20
+ schema: StandardSchemaV1;
21
+ isWildcard: boolean;
22
+ isAncestor: boolean;
23
+ segments: ReadonlyArray<PatternSegment>;
24
+ }
25
+ export interface Registry {
26
+ entries: ReadonlyArray<SchemaEntry>;
27
+ leafOwner: Map<string, SchemaEntry>;
28
+ pathStates: Map<string, PathState>;
29
+ errorsAtCache: Map<string, Signal<string | null>>;
30
+ isValidAtCache: Map<string, Signal<boolean>>;
31
+ isPendingAtCache: Map<string, Signal<boolean>>;
32
+ invalidCount: WritableSignal<number>;
33
+ activeAncestorRuns: Map<string, AncestorRunRecord>;
34
+ nextAncestorRunId: number;
35
+ pendingPathsSignal: WritableSignal<readonly string[]>;
36
+ boundPathsSignal: WritableSignal<readonly string[]>;
37
+ boundPathsSet: Set<string>;
38
+ pendingPathsSet: Set<string>;
39
+ config: SchemaConfig;
40
+ }
41
+ export declare function createPathState(): PathState;
42
+ export declare function ensurePathState(registry: Registry, path: string): PathState;
43
+ export declare function addBoundPath(registry: Registry, path: string): void;
44
+ export declare function removeBoundPath(registry: Registry, path: string): void;
45
+ export declare function addPendingPath(registry: Registry, path: string): void;
46
+ export declare function removePendingPath(registry: Registry, path: string): void;
@@ -0,0 +1,3 @@
1
+ import { type ISignalTree } from '@signaltree/core';
2
+ import type { SchemaConfig, SchemaMethods } from './types';
3
+ export declare function schemas(config: SchemaConfig): <T>(tree: ISignalTree<T>) => ISignalTree<T> & SchemaMethods;
@@ -0,0 +1,30 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
2
+ import type { Signal } from '@angular/core';
3
+ import type { UpdateMetadata } from '@signaltree/core';
4
+ export type SchemaPath = string;
5
+ export interface SchemaConfig {
6
+ schemas: Readonly<Record<SchemaPath, StandardSchemaV1>>;
7
+ mode?: 'accept' | 'warn';
8
+ validateOnAttach?: boolean;
9
+ suppressIntents?: ReadonlyArray<NonNullable<UpdateMetadata['intent']>>;
10
+ suppressSources?: ReadonlyArray<NonNullable<UpdateMetadata['source']>>;
11
+ onError?: (path: string, message: string) => void;
12
+ formatIssue?: (issue: StandardSchemaV1.Issue, path: string) => string;
13
+ }
14
+ export interface SchemaMethods {
15
+ schemas: {
16
+ readonly errors: Signal<Readonly<Record<string, string | null>>>;
17
+ readonly errorList: Signal<readonly string[]>;
18
+ readonly isValid: Signal<boolean>;
19
+ readonly pending: Signal<boolean>;
20
+ readonly pendingPaths: Signal<readonly string[]>;
21
+ errorsAt(path: string): Signal<string | null>;
22
+ isValidAt(path: string): Signal<boolean>;
23
+ isPendingAt(path: string): Signal<boolean>;
24
+ validate(): Promise<boolean>;
25
+ validatePath(path: string): Promise<boolean>;
26
+ compact(): void;
27
+ schemaFor(leafPath: string): StandardSchemaV1 | undefined;
28
+ readonly boundPaths: Signal<readonly string[]>;
29
+ };
30
+ }