@zvenigora/ng-eval-forms 0.1.0 → 0.2.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.
@@ -1,4 +1,4 @@
1
- import { createSignalContext } from '@zvenigora/ng-eval-signals';
1
+ import { SignalContextWriteError, createSignalContext } from '@zvenigora/ng-eval-signals';
2
2
 
3
3
  /**
4
4
  * Coerces an expression's result to the boolean a `visible` rule needs.
@@ -46,6 +46,94 @@ const toVisible = (value) => !!value;
46
46
  */
47
47
  const toText = (value) => value === null || value === undefined ? '' : String(value);
48
48
 
49
+ // A **value** import, and it has to stay one: `instanceof` needs the
50
+ // constructor, not the type. `field-context.ts` already imports a value from
51
+ // this package, so this adds no dependency edge.
52
+ //
53
+ // **The compiler enforces the form, and the plan said otherwise** - S 3.4 and
54
+ // S 8.1 both call a type-only import here a change that "would compile and the
55
+ // guard would silently never fire". Measured in step 3: `import type` is
56
+ // **TS1361** at `applyErrorPolicy`'s `instanceof`, so it fails
57
+ // `build:production` and gate 7, and under ts-jest's transpile path the elided
58
+ // binding is a `ReferenceError` inside the `catch` that reddens *both* policy
59
+ // arms. Loud at every gate, in other words - see the plan's revision 13.
60
+ //
61
+ // The failure mode that *is* silent is a different one and no import syntax
62
+ // prevents it: two resolved copies of `@zvenigora/ng-eval-signals` give two
63
+ // distinct constructors, and `instanceof` is then false for an error the other
64
+ // copy threw. That is what the write-error arm of `evaluate-rule.spec.ts`
65
+ // covers by throwing across the real package boundary rather than
66
+ // constructing the error itself.
67
+ /**
68
+ * Runs one rule under one {@link ExpressionErrorPolicy}, with the one error
69
+ * that must never be routed through it taken out first.
70
+ *
71
+ * ```ts
72
+ * applyErrorPolicy(() => evaluateRule(compiled, context, options), onError)
73
+ * ```
74
+ *
75
+ * **`SignalContextWriteError` re-throws in every mode, including a handler
76
+ * function** (plan S 3.4, 1.2.11). On `/reactive` that bypass came free -
77
+ * `createEvalSignal` re-throws it regardless of `onError` (`eval-signal.ts:353`)
78
+ * - and that behaviour lives *inside* `createEvalSignal`, which the `/signals`
79
+ * path never calls. It does not travel with the type, so it is re-implemented
80
+ * here or it does not exist. An assigning expression is illegal on every
81
+ * recompute with every dataset, so under this module's default of
82
+ * `'undefined'` it would otherwise render as a permanently blank field with
83
+ * nothing in the console.
84
+ *
85
+ * **The `instanceof` needs the constructor, so the import of that class is a
86
+ * *value* import** - see the note above it, and the plan's revision 13, for
87
+ * why that form is protected by the compiler rather than by this spec.
88
+ * `evaluate-rule.spec.ts`'s write-error arm asserts on the **class** rather
89
+ * than on a message because what it covers is the bypass's *behaviour* across
90
+ * a real package boundary, which no compiler check reaches.
91
+ *
92
+ * **It lives in the core while `/signals`' choke point does not, and the pair
93
+ * is not inconsistent** (S 3.4.1). The discriminator is what the symbol grants
94
+ * a caller who reaches it: `evaluateRule` published *is* a second path to the
95
+ * walk, which S 9.1 requires there be only one of, while this is a pure
96
+ * function over an error and a policy that takes no context, holds no compiled
97
+ * callback and cannot reach a walk. Publishing it grants a caller nothing they
98
+ * could not write in four lines, and buys the thing duplication would lose -
99
+ * one implementation of the write-error bypass for both adapters, which cannot
100
+ * then drift into disagreeing about the one error that must never be
101
+ * swallowed.
102
+ *
103
+ * **It re-throws the error it caught; `/reactive` re-wraps.** `createEvalSignal`
104
+ * throws a *new* `SignalContextWriteError` carrying the offending expression
105
+ * string, which it has and this path does not - a `LogicFn` holds a compiled
106
+ * callback, not the source text. So the same misuse surfaces a different
107
+ * object at each entry point: wrapped with the expression under `/reactive`,
108
+ * the original here. Stated rather than hidden; it is the one thing a consumer
109
+ * catching this error across both adapters would notice.
110
+ *
111
+ * @param run - The rule invocation. On `/signals` this is the choke point,
112
+ * never a bare walk: containment is inside `run`, so a throw is
113
+ * contained before this function decides what to do about it.
114
+ * @param policy - Defaults to `'undefined'`, per {@link ExpressionErrorPolicy}.
115
+ * Resolving the default *here* is what makes an absent policy
116
+ * mean `'undefined'` rather than inheriting `eval-signals`'
117
+ * opposite one.
118
+ */
119
+ const applyErrorPolicy = (run, policy = 'undefined') => {
120
+ try {
121
+ return run();
122
+ }
123
+ catch (error) {
124
+ if (error instanceof SignalContextWriteError) {
125
+ throw error;
126
+ }
127
+ if (policy === 'throw') {
128
+ throw error;
129
+ }
130
+ if (policy === 'undefined') {
131
+ return undefined;
132
+ }
133
+ return policy(error);
134
+ }
135
+ };
136
+
49
137
  /**
50
138
  * Composes one `EvalContext` for one field, out of two live sources.
51
139
  *
@@ -128,5 +216,5 @@ const createFieldContext = (formSource, fieldSource, options) => {
128
216
  * Generated bundle index. Do not edit.
129
217
  */
130
218
 
131
- export { createFieldContext, toText, toVisible };
219
+ export { applyErrorPolicy, createFieldContext, toText, toVisible };
132
220
  //# sourceMappingURL=zvenigora-ng-eval-forms.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"zvenigora-ng-eval-forms.mjs","sources":["../../../../modules/eval-forms/src/lib/coercion.ts","../../../../modules/eval-forms/src/lib/field-context.ts","../../../../modules/eval-forms/src/zvenigora-ng-eval-forms.ts"],"sourcesContent":["/**\r\n * Coerces an expression's result to the boolean a `visible` rule needs.\r\n *\r\n * **JavaScript truthiness, and nothing cleverer** (plan S 3.6). A rule author\r\n * writing `visible: \"country === 'US'\"` gets a boolean already; the coercion\r\n * exists for the ones who write `visible: \"country\"`.\r\n *\r\n * `undefined` is therefore the load-bearing input rather than an edge case.\r\n * An *empty* field and a *missing* field are indistinguishable here - S 3.4.3:\r\n * a field key bound to `undefined` is treated as absent, and an empty\r\n * `FormControl` is the common case - so both arrive as `undefined`, and both\r\n * mean \"not visible\".\r\n *\r\n * The string `'false'` is **visible**, because it is a non-empty string. That\r\n * is truthiness rather than parsing, and it is the answer a form-builder UI\r\n * storing every value as a string will hit. Documented in the README rather\r\n * than special-cased: a coercion that parsed `'false'` would then owe an\r\n * answer for `'no'`, `'0'` and `'off'`, none of which JavaScript has one for.\r\n *\r\n * Both adapters call this, which is why it is in the core: `/reactive` and\r\n * `/signals` differ in how they schedule the recompute and not in what the\r\n * result means (S 3.4.5).\r\n *\r\n * @param value - Whatever the expression evaluated to.\r\n * @returns `true` when the value is truthy.\r\n */\r\nexport const toVisible = (value: unknown): boolean => !!value;\r\n\r\n/**\r\n * Coerces an expression's result to the string a `text` rule needs.\r\n *\r\n * `String(value)`, with `null` and `undefined` mapping to `''` (plan S 3.6).\r\n * Those two are the carve-out and the only one: rendering the literal text\r\n * `undefined` into a label is the failure this rule exists to prevent, and it\r\n * is what a bare `String(value)` would do for the most ordinary input a form\r\n * has - the empty control of S 3.4.3.\r\n *\r\n * Every *other* falsy value stringifies normally: `0` is `'0'` and `false` is\r\n * `'false'`. Mapping all falsy values to `''` would be the obvious way to\r\n * write this rule wrongly, and it would blank a field whose value is\r\n * legitimately zero.\r\n *\r\n * @param value - Whatever the expression evaluated to.\r\n * @returns The value as a string, or `''` when it is `null` or `undefined`.\r\n */\r\nexport const toText = (value: unknown): string =>\r\n value === null || value === undefined ? '' : String(value);\r\n","import { EvalContext, EvalOptions } from '@zvenigora/ng-eval-core';\r\nimport { SignalContextSource, createSignalContext } from '@zvenigora/ng-eval-signals';\r\n\r\n/**\r\n * Composes one `EvalContext` for one field, out of two live sources.\r\n *\r\n * The mechanism is **two lookups, not a joined record** (plan S 3.4.2). The\r\n * field half is a `createSignalContext` over `fieldSource`; the form half is a\r\n * second resolver pushed onto the same context's `lookups`. Both close over\r\n * their source and read it at resolve time, so a key added to either after\r\n * construction resolves - there is no third object to keep in sync, and no\r\n * write-through to N contexts.\r\n *\r\n * `EvalContext.get` walks `lookups` in order and takes the first\r\n * non-`undefined`, so the field half is consulted first.\r\n *\r\n * One context per field, never one shared across fields (plan S 3.4.1):\r\n * `get` resolves `scopes` and `original` *before* `lookups`, so anything left\r\n * on a shared context - an arrow function's leaked scope, say - would be read\r\n * ahead of every other field's source key of the same name, for the life of\r\n * the form.\r\n *\r\n * **Resolution is live; appearance is not reactive.** An expression that read\r\n * a then-missing key subscribed to nothing, so the owner of the source calls\r\n * `invalidate()` when the *key set* changes. A change to a *value* is\r\n * Angular's job and needs nothing.\r\n *\r\n * **Precedence is value-dependent, not permanent.** A field key wins while\r\n * its value is *present*: `EvalContext.get` treats `undefined` as absent at\r\n * every step, and the field resolver returns `undefined` both for \"no such\r\n * key\" and for \"key bound to `undefined`\" - so an empty `FormControl`, which\r\n * is the common case rather than an exotic one, falls through to the form\r\n * value. Documented as a limitation (plan S 3.4.3): distinguishing the two\r\n * would need a sentinel threaded through `EvalContext.get`, which is\r\n * `eval-core`'s and out of this phase's scope.\r\n *\r\n * @param formSource - The form-wide keys, shared by every field of the form.\r\n * @param fieldSource - The field-local keys. These win on a name collision,\r\n * while their value is not `undefined`.\r\n * @param options - Passed to `createSignalContext` for **both** halves;\r\n * configures this context and its resolvers, not the walk.\r\n * As upstream, `caseInsensitive` corrects identifier keys\r\n * here but not *property* names - the member visitor reads\r\n * those off the state's options, so the same options must\r\n * also reach `simpleEval` / `createState`.\r\n */\r\nexport const createFieldContext = (\r\n formSource: SignalContextSource,\r\n fieldSource: SignalContextSource,\r\n options?: EvalOptions\r\n): EvalContext => {\r\n\r\n const context = createSignalContext(fieldSource, options);\r\n\r\n // The form half **borrows upstream's resolver rather than rewriting it**\r\n // (S 3.4.2, candidate A, settled in step 2). The second context is\r\n // discarded and only its closure survives, so this is still one\r\n // `EvalContext` per field and the field half is still consulted first.\r\n //\r\n // The deciding reason is row 2 of S 3.4.2's measured table, and it is that\r\n // the hand-written read is *wrong*, not merely thinner: it returns a signal\r\n // un-called, so a form key holding `signal(undefined)` resolves to the\r\n // signal **function**, which is truthy - and `visible: \"country\"` then\r\n // renders a field precisely when its value is absent, silently, on an empty\r\n // control. That is every form's first render. Recorded here because the\r\n // alternative was rejected on correctness and stays rejected even once\r\n // S 3.5 names a mechanism that keeps a plain-value record live.\r\n //\r\n // Borrowing also brings own-property semantics - so a server-supplied field\r\n // named `constructor` cannot resolve off `Object.prototype` *through this\r\n // half*, which is a narrower claim than it looks and no test reaches it:\r\n // `get` consults `original` before `lookups`, and `getContextValue` reads a\r\n // plain object as a bare property access, so an inherited name resolves\r\n // there first and never arrives here (S 3.4.3's third layer, upstream and\r\n // out of this phase's scope). It also brings `caseInsensitive` correction\r\n // and `warnOnNestedSignals` over the form source, with no import beyond the\r\n // one already here.\r\n //\r\n // The scan is the one cost: `createSignalContext` runs\r\n // `warnOnNestedSignals` unconditionally, so a form of N fields scans the\r\n // same form source N times and, in dev mode, would warn N times under\r\n // *upstream's* symbol name for a call this library made. Construction-time\r\n // only - no per-node or per-recompute path is touched.\r\n context.lookups.push(...createSignalContext(formSource, options).lookups);\r\n\r\n return context;\r\n};\r\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":";;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;AAyBG;AACI,MAAM,SAAS,GAAG,CAAC,KAAc,KAAc,CAAC,CAAC;AAExD;;;;;;;;;;;;;;;;AAgBG;AACI,MAAM,MAAM,GAAG,CAAC,KAAc,KACnC,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,GAAG,EAAE,GAAG,MAAM,CAAC,KAAK;;AC3C3D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0CG;AACI,MAAM,kBAAkB,GAAG,CAChC,UAA+B,EAC/B,WAAgC,EAChC,OAAqB,KACN;IAEf,MAAM,OAAO,GAAG,mBAAmB,CAAC,WAAW,EAAE,OAAO,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BzD,IAAA,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,mBAAmB,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC,OAAO,CAAC;AAEzE,IAAA,OAAO,OAAO;AAChB;;ACtFA;;AAEG;;;;"}
1
+ {"version":3,"file":"zvenigora-ng-eval-forms.mjs","sources":["../../../../modules/eval-forms/src/lib/coercion.ts","../../../../modules/eval-forms/src/lib/error-policy.ts","../../../../modules/eval-forms/src/lib/field-context.ts","../../../../modules/eval-forms/src/zvenigora-ng-eval-forms.ts"],"sourcesContent":["/**\r\n * Coerces an expression's result to the boolean a `visible` rule needs.\r\n *\r\n * **JavaScript truthiness, and nothing cleverer** (plan S 3.6). A rule author\r\n * writing `visible: \"country === 'US'\"` gets a boolean already; the coercion\r\n * exists for the ones who write `visible: \"country\"`.\r\n *\r\n * `undefined` is therefore the load-bearing input rather than an edge case.\r\n * An *empty* field and a *missing* field are indistinguishable here - S 3.4.3:\r\n * a field key bound to `undefined` is treated as absent, and an empty\r\n * `FormControl` is the common case - so both arrive as `undefined`, and both\r\n * mean \"not visible\".\r\n *\r\n * The string `'false'` is **visible**, because it is a non-empty string. That\r\n * is truthiness rather than parsing, and it is the answer a form-builder UI\r\n * storing every value as a string will hit. Documented in the README rather\r\n * than special-cased: a coercion that parsed `'false'` would then owe an\r\n * answer for `'no'`, `'0'` and `'off'`, none of which JavaScript has one for.\r\n *\r\n * Both adapters call this, which is why it is in the core: `/reactive` and\r\n * `/signals` differ in how they schedule the recompute and not in what the\r\n * result means (S 3.4.5).\r\n *\r\n * @param value - Whatever the expression evaluated to.\r\n * @returns `true` when the value is truthy.\r\n */\r\nexport const toVisible = (value: unknown): boolean => !!value;\r\n\r\n/**\r\n * Coerces an expression's result to the string a `text` rule needs.\r\n *\r\n * `String(value)`, with `null` and `undefined` mapping to `''` (plan S 3.6).\r\n * Those two are the carve-out and the only one: rendering the literal text\r\n * `undefined` into a label is the failure this rule exists to prevent, and it\r\n * is what a bare `String(value)` would do for the most ordinary input a form\r\n * has - the empty control of S 3.4.3.\r\n *\r\n * Every *other* falsy value stringifies normally: `0` is `'0'` and `false` is\r\n * `'false'`. Mapping all falsy values to `''` would be the obvious way to\r\n * write this rule wrongly, and it would blank a field whose value is\r\n * legitimately zero.\r\n *\r\n * @param value - Whatever the expression evaluated to.\r\n * @returns The value as a string, or `''` when it is `null` or `undefined`.\r\n */\r\nexport const toText = (value: unknown): string =>\r\n value === null || value === undefined ? '' : String(value);\r\n","// A **value** import, and it has to stay one: `instanceof` needs the\r\n// constructor, not the type. `field-context.ts` already imports a value from\r\n// this package, so this adds no dependency edge.\r\n//\r\n// **The compiler enforces the form, and the plan said otherwise** - S 3.4 and\r\n// S 8.1 both call a type-only import here a change that \"would compile and the\r\n// guard would silently never fire\". Measured in step 3: `import type` is\r\n// **TS1361** at `applyErrorPolicy`'s `instanceof`, so it fails\r\n// `build:production` and gate 7, and under ts-jest's transpile path the elided\r\n// binding is a `ReferenceError` inside the `catch` that reddens *both* policy\r\n// arms. Loud at every gate, in other words - see the plan's revision 13.\r\n//\r\n// The failure mode that *is* silent is a different one and no import syntax\r\n// prevents it: two resolved copies of `@zvenigora/ng-eval-signals` give two\r\n// distinct constructors, and `instanceof` is then false for an error the other\r\n// copy threw. That is what the write-error arm of `evaluate-rule.spec.ts`\r\n// covers by throwing across the real package boundary rather than\r\n// constructing the error itself.\r\nimport { SignalContextWriteError } from '@zvenigora/ng-eval-signals';\r\n\r\n/**\r\n * What a field property does when its expression throws at runtime.\r\n *\r\n * - `'throw'` - rethrow, matching `eval-signals`' own default.\r\n * - `'undefined'` - the property resolves `undefined`, which `toVisible`\r\n * reads as not visible and `toText` reads as `''`.\r\n * - a function - called with the error; its return value becomes the\r\n * property's value.\r\n *\r\n * **The default here is `'undefined'`, the opposite of `eval-signals`'**, and\r\n * the consumer is the reason (plan S 3.4.4). An expression that fails in\r\n * `eval-signals` was written by the developer reading the stack trace. An\r\n * expression that fails here may have been typed into a form builder by an\r\n * end user, and the right response to \"the admin wrote a bad rule\" is a field\r\n * that does not render, not an application that throws on every change\r\n * detection pass. Consumers who want the strict behaviour pass `'throw'`.\r\n *\r\n * Resolving that default is a real step and not a formality: `createEvalSignal`\r\n * does `options?.onError ?? 'throw'`, so forwarding an *absent* policy verbatim\r\n * inherits `'throw'` - the opposite of this default. The `/reactive` binding\r\n * substitutes its own before the call.\r\n *\r\n * The union is structurally identical to `EvalSignalOptions['onError']`\r\n * **deliberately**: `/reactive` resolves the default here and forwards the\r\n * value to `createEvalSignal` with no mapping between the two unions, which is\r\n * where two near-identical types would drift apart.\r\n *\r\n * `SignalContextWriteError` is **not** routed through this, in either adapter,\r\n * matching `phase-3-plan.md` S 3.6.3. A write violation is static - illegal on\r\n * every recompute with every dataset - and swallowing it under a default of\r\n * `'undefined'` would hand every consumer a silent blank for a bug in the\r\n * rule's own syntax. `createEvalSignal` already bypasses `onError` for it.\r\n *\r\n * **{@link applyErrorPolicy} ships beside this type as of Phase 6** (its\r\n * S 3.4). Through Phase 4 only the type shipped, because the helper would have\r\n * had no caller: on the `/reactive` path - the only path Phase 4 shipped -\r\n * `createEvalSignal` applies the three cases internally, and shipping an\r\n * unexercised code path is what S 9.1 declined to do for the scope-leak\r\n * containment. The `/signals` adapter is that caller **from step 4**, where\r\n * the registrars stop being stubs; `/reactive` does not use the helper at all.\r\n */\r\nexport type ExpressionErrorPolicy =\r\n | 'throw'\r\n | 'undefined'\r\n | ((error: unknown) => unknown);\r\n\r\n/**\r\n * Runs one rule under one {@link ExpressionErrorPolicy}, with the one error\r\n * that must never be routed through it taken out first.\r\n *\r\n * ```ts\r\n * applyErrorPolicy(() => evaluateRule(compiled, context, options), onError)\r\n * ```\r\n *\r\n * **`SignalContextWriteError` re-throws in every mode, including a handler\r\n * function** (plan S 3.4, 1.2.11). On `/reactive` that bypass came free -\r\n * `createEvalSignal` re-throws it regardless of `onError` (`eval-signal.ts:353`)\r\n * - and that behaviour lives *inside* `createEvalSignal`, which the `/signals`\r\n * path never calls. It does not travel with the type, so it is re-implemented\r\n * here or it does not exist. An assigning expression is illegal on every\r\n * recompute with every dataset, so under this module's default of\r\n * `'undefined'` it would otherwise render as a permanently blank field with\r\n * nothing in the console.\r\n *\r\n * **The `instanceof` needs the constructor, so the import of that class is a\r\n * *value* import** - see the note above it, and the plan's revision 13, for\r\n * why that form is protected by the compiler rather than by this spec.\r\n * `evaluate-rule.spec.ts`'s write-error arm asserts on the **class** rather\r\n * than on a message because what it covers is the bypass's *behaviour* across\r\n * a real package boundary, which no compiler check reaches.\r\n *\r\n * **It lives in the core while `/signals`' choke point does not, and the pair\r\n * is not inconsistent** (S 3.4.1). The discriminator is what the symbol grants\r\n * a caller who reaches it: `evaluateRule` published *is* a second path to the\r\n * walk, which S 9.1 requires there be only one of, while this is a pure\r\n * function over an error and a policy that takes no context, holds no compiled\r\n * callback and cannot reach a walk. Publishing it grants a caller nothing they\r\n * could not write in four lines, and buys the thing duplication would lose -\r\n * one implementation of the write-error bypass for both adapters, which cannot\r\n * then drift into disagreeing about the one error that must never be\r\n * swallowed.\r\n *\r\n * **It re-throws the error it caught; `/reactive` re-wraps.** `createEvalSignal`\r\n * throws a *new* `SignalContextWriteError` carrying the offending expression\r\n * string, which it has and this path does not - a `LogicFn` holds a compiled\r\n * callback, not the source text. So the same misuse surfaces a different\r\n * object at each entry point: wrapped with the expression under `/reactive`,\r\n * the original here. Stated rather than hidden; it is the one thing a consumer\r\n * catching this error across both adapters would notice.\r\n *\r\n * @param run - The rule invocation. On `/signals` this is the choke point,\r\n * never a bare walk: containment is inside `run`, so a throw is\r\n * contained before this function decides what to do about it.\r\n * @param policy - Defaults to `'undefined'`, per {@link ExpressionErrorPolicy}.\r\n * Resolving the default *here* is what makes an absent policy\r\n * mean `'undefined'` rather than inheriting `eval-signals`'\r\n * opposite one.\r\n */\r\nexport const applyErrorPolicy = <T>(\r\n run: () => T,\r\n policy: ExpressionErrorPolicy = 'undefined'\r\n): T | undefined => {\r\n\r\n try {\r\n return run();\r\n } catch (error) {\r\n if (error instanceof SignalContextWriteError) {\r\n throw error;\r\n }\r\n\r\n if (policy === 'throw') {\r\n throw error;\r\n }\r\n\r\n if (policy === 'undefined') {\r\n return undefined;\r\n }\r\n\r\n return policy(error) as T | undefined;\r\n }\r\n};\r\n","import { EvalContext, EvalOptions } from '@zvenigora/ng-eval-core';\r\nimport { SignalContextSource, createSignalContext } from '@zvenigora/ng-eval-signals';\r\n\r\n/**\r\n * Composes one `EvalContext` for one field, out of two live sources.\r\n *\r\n * The mechanism is **two lookups, not a joined record** (plan S 3.4.2). The\r\n * field half is a `createSignalContext` over `fieldSource`; the form half is a\r\n * second resolver pushed onto the same context's `lookups`. Both close over\r\n * their source and read it at resolve time, so a key added to either after\r\n * construction resolves - there is no third object to keep in sync, and no\r\n * write-through to N contexts.\r\n *\r\n * `EvalContext.get` walks `lookups` in order and takes the first\r\n * non-`undefined`, so the field half is consulted first.\r\n *\r\n * One context per field, never one shared across fields (plan S 3.4.1):\r\n * `get` resolves `scopes` and `original` *before* `lookups`, so anything left\r\n * on a shared context - an arrow function's leaked scope, say - would be read\r\n * ahead of every other field's source key of the same name, for the life of\r\n * the form.\r\n *\r\n * **Resolution is live; appearance is not reactive.** An expression that read\r\n * a then-missing key subscribed to nothing, so the owner of the source calls\r\n * `invalidate()` when the *key set* changes. A change to a *value* is\r\n * Angular's job and needs nothing.\r\n *\r\n * **Precedence is value-dependent, not permanent.** A field key wins while\r\n * its value is *present*: `EvalContext.get` treats `undefined` as absent at\r\n * every step, and the field resolver returns `undefined` both for \"no such\r\n * key\" and for \"key bound to `undefined`\" - so an empty `FormControl`, which\r\n * is the common case rather than an exotic one, falls through to the form\r\n * value. Documented as a limitation (plan S 3.4.3): distinguishing the two\r\n * would need a sentinel threaded through `EvalContext.get`, which is\r\n * `eval-core`'s and out of this phase's scope.\r\n *\r\n * @param formSource - The form-wide keys, shared by every field of the form.\r\n * @param fieldSource - The field-local keys. These win on a name collision,\r\n * while their value is not `undefined`.\r\n * @param options - Passed to `createSignalContext` for **both** halves;\r\n * configures this context and its resolvers, not the walk.\r\n * As upstream, `caseInsensitive` corrects identifier keys\r\n * here but not *property* names - the member visitor reads\r\n * those off the state's options, so the same options must\r\n * also reach `simpleEval` / `createState`.\r\n */\r\nexport const createFieldContext = (\r\n formSource: SignalContextSource,\r\n fieldSource: SignalContextSource,\r\n options?: EvalOptions\r\n): EvalContext => {\r\n\r\n const context = createSignalContext(fieldSource, options);\r\n\r\n // The form half **borrows upstream's resolver rather than rewriting it**\r\n // (S 3.4.2, candidate A, settled in step 2). The second context is\r\n // discarded and only its closure survives, so this is still one\r\n // `EvalContext` per field and the field half is still consulted first.\r\n //\r\n // The deciding reason is row 2 of S 3.4.2's measured table, and it is that\r\n // the hand-written read is *wrong*, not merely thinner: it returns a signal\r\n // un-called, so a form key holding `signal(undefined)` resolves to the\r\n // signal **function**, which is truthy - and `visible: \"country\"` then\r\n // renders a field precisely when its value is absent, silently, on an empty\r\n // control. That is every form's first render. Recorded here because the\r\n // alternative was rejected on correctness and stays rejected even once\r\n // S 3.5 names a mechanism that keeps a plain-value record live.\r\n //\r\n // Borrowing also brings own-property semantics - so a server-supplied field\r\n // named `constructor` cannot resolve off `Object.prototype` *through this\r\n // half*, which is a narrower claim than it looks and no test reaches it:\r\n // `get` consults `original` before `lookups`, and `getContextValue` reads a\r\n // plain object as a bare property access, so an inherited name resolves\r\n // there first and never arrives here (S 3.4.3's third layer, upstream and\r\n // out of this phase's scope). It also brings `caseInsensitive` correction\r\n // and `warnOnNestedSignals` over the form source, with no import beyond the\r\n // one already here.\r\n //\r\n // The scan is the one cost: `createSignalContext` runs\r\n // `warnOnNestedSignals` unconditionally, so a form of N fields scans the\r\n // same form source N times and, in dev mode, would warn N times under\r\n // *upstream's* symbol name for a call this library made. Construction-time\r\n // only - no per-node or per-recompute path is touched.\r\n context.lookups.push(...createSignalContext(formSource, options).lookups);\r\n\r\n return context;\r\n};\r\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":";;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;AAyBG;AACI,MAAM,SAAS,GAAG,CAAC,KAAc,KAAc,CAAC,CAAC;AAExD;;;;;;;;;;;;;;;;AAgBG;AACI,MAAM,MAAM,GAAG,CAAC,KAAc,KACnC,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,GAAG,EAAE,GAAG,MAAM,CAAC,KAAK;;AC9C3D;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AAiDA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmDG;AACI,MAAM,gBAAgB,GAAG,CAC9B,GAAY,EACZ,MAAA,GAAgC,WAAW,KAC1B;AAEjB,IAAA,IAAI;QACF,OAAO,GAAG,EAAE;IACd;IAAE,OAAO,KAAK,EAAE;AACd,QAAA,IAAI,KAAK,YAAY,uBAAuB,EAAE;AAC5C,YAAA,MAAM,KAAK;QACb;AAEA,QAAA,IAAI,MAAM,KAAK,OAAO,EAAE;AACtB,YAAA,MAAM,KAAK;QACb;AAEA,QAAA,IAAI,MAAM,KAAK,WAAW,EAAE;AAC1B,YAAA,OAAO,SAAS;QAClB;AAEA,QAAA,OAAO,MAAM,CAAC,KAAK,CAAkB;IACvC;AACF;;ACzIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0CG;AACI,MAAM,kBAAkB,GAAG,CAChC,UAA+B,EAC/B,WAAgC,EAChC,OAAqB,KACN;IAEf,MAAM,OAAO,GAAG,mBAAmB,CAAC,WAAW,EAAE,OAAO,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BzD,IAAA,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,mBAAmB,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC,OAAO,CAAC;AAEzE,IAAA,OAAO,OAAO;AAChB;;ACtFA;;AAEG;;;;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvenigora/ng-eval-forms",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Angular form field properties from ng-eval expressions",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,9 +18,13 @@
18
18
  "@angular/core": ">=19.0.0",
19
19
  "@angular/forms": ">=19.0.0",
20
20
  "rxjs": "^7.8.0",
21
+ "acorn-walk": "^8.3.0",
21
22
  "@zvenigora/ng-eval-core": "^0.3.0",
22
23
  "@zvenigora/ng-eval-signals": "^0.1.0"
23
24
  },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
24
28
  "sideEffects": false,
25
29
  "module": "fesm2022/zvenigora-ng-eval-forms.mjs",
26
30
  "typings": "types/zvenigora-ng-eval-forms.d.ts",
@@ -35,6 +39,10 @@
35
39
  "./reactive": {
36
40
  "types": "./types/zvenigora-ng-eval-forms-reactive.d.ts",
37
41
  "default": "./fesm2022/zvenigora-ng-eval-forms-reactive.mjs"
42
+ },
43
+ "./signals": {
44
+ "types": "./types/zvenigora-ng-eval-forms-signals.d.ts",
45
+ "default": "./fesm2022/zvenigora-ng-eval-forms-signals.mjs"
38
46
  }
39
47
  },
40
48
  "type": "module",
@@ -0,0 +1,135 @@
1
+ import * as _angular_core from '@angular/core';
2
+ import { WritableSignal } from '@angular/core';
3
+ import * as _angular_forms_signals from '@angular/forms/signals';
4
+ import { PathKind, SchemaPath, SchemaPathRules } from '@angular/forms/signals';
5
+ import { EvalOptions } from '@zvenigora/ng-eval-core';
6
+ import { ExpressionErrorPolicy } from '@zvenigora/ng-eval-forms';
7
+
8
+ /**
9
+ * What `createExpressionRules` takes, and what a single registration may
10
+ * override (plan S 5).
11
+ */
12
+ interface ExpressionRuleOptions {
13
+ /**
14
+ * Passed to the context and to the walk. `caseInsensitive` has to reach
15
+ * both: on the context it corrects identifier keys, and on the walk it
16
+ * corrects *property* names, which the member visitor reads off the
17
+ * state's options.
18
+ *
19
+ * **A per-registration value reaches exactly one of the three places it
20
+ * has to: the walk** (plan S 3.5.3). The other two read the **factory's**
21
+ * options, which are fixed when `createExpressionRules` is called: the memo
22
+ * is built once, there; the context is minted per registration by
23
+ * `createRuleContext()` but always from that same fixed setting, so a
24
+ * registration cannot move it. So overriding this per registration corrects
25
+ * *property* names and leaves *identifier* keys on the factory's setting,
26
+ * and one expression then obeys two casing rules. Set `caseInsensitive` on
27
+ * the **factory** unless that is the behaviour you want.
28
+ *
29
+ * Revision 19 item 2 corrects "both are made once, at
30
+ * `createExpressionRules` time", which was wrong about the context and is
31
+ * the claim the README states correctly.
32
+ */
33
+ eval?: EvalOptions;
34
+ /**
35
+ * What a field property does when its expression throws at runtime.
36
+ * Defaults to `'undefined'` here, the opposite of `eval-signals`' own
37
+ * default - see `ExpressionErrorPolicy` for why.
38
+ */
39
+ onError?: ExpressionErrorPolicy;
40
+ }
41
+ /**
42
+ * The three registrars one factory returns, one per Angular rule.
43
+ *
44
+ * **One call per Angular primitive, never an aggregate** (plan S 3.5.1). A
45
+ * single `evalRules(p.x, { visible, text, disabled })` would register three
46
+ * different Angular rules behind one name, hiding which one each property
47
+ * maps to - which is the `/signals` reviewer checklist's item 4 expressed as
48
+ * an API.
49
+ *
50
+ * Named after the **property**, not after Angular's rule: `evalVisible`
51
+ * registers `hidden`, inverted once inside the registrar rather than in every
52
+ * consumer's expression, so the same expression string means the same thing
53
+ * at this entry point and at `/reactive`.
54
+ */
55
+ interface ExpressionRules {
56
+ /** Registers Angular's `hidden`, inverted (plan S 3.5.1). */
57
+ evalVisible: <TValue, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, expression: string, options?: ExpressionRuleOptions) => void;
58
+ /** Registers `metadata(path, TEXT, …)` (plan S 1.2.8, S 3.5). */
59
+ evalText: <TValue, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, expression: string, options?: ExpressionRuleOptions) => void;
60
+ /**
61
+ * Registers Angular's `disabled`.
62
+ *
63
+ * The `reason` is authored as a static string and is never
64
+ * expression-derived (plan S 3.5.2). Angular's `when` returns
65
+ * `boolean | string` and a truthy string is *both* "disabled" and "the
66
+ * reason", so a rule yielding `'false'` would otherwise disable the field
67
+ * with the reason `"false"` - the `toVisible` truthiness trap in a new
68
+ * shape. Keeping the expression boolean and the reason static is what kills
69
+ * it.
70
+ */
71
+ evalDisabled: <TValue, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, expression: string, options?: ExpressionRuleOptions & {
72
+ reason?: string;
73
+ }) => void;
74
+ }
75
+ /**
76
+ * Binds one model signal and returns the three expression-driven registrars.
77
+ *
78
+ * **A factory rather than free functions, because a `LogicFn` cannot recover
79
+ * the source** (plan S 3.2.1). `RootFieldContext` exposes the *current*
80
+ * field's node plus compile-time-token accessors and no root or parent
81
+ * handle, so a rule on `p.city` evaluating `country === "US"` has no route to
82
+ * `country` from inside the `LogicFn`. The source must be closed over at
83
+ * registration or it is unreachable.
84
+ *
85
+ * ```ts
86
+ * const rules = createExpressionRules(model);
87
+ * const s = schema<Model>((p) => {
88
+ * required(p.email); // Angular's
89
+ * rules.evalVisible(p.city, 'country === "US"'); // ours
90
+ * });
91
+ * const f = form(model, s);
92
+ * ```
93
+ *
94
+ * **A schema *value* shared across models is the unsupported shape.** Nothing
95
+ * stops two `form()` calls from one schema - Angular re-invokes the schema
96
+ * body once per `form()`, so each form mints its own contexts - but the
97
+ * registrars close over the **factory's** model, and the factory is bound to
98
+ * one. A schema built from `createExpressionRules(modelA)` and reused for
99
+ * `form(modelB, s)` re-registers every rule and every one of them still reads
100
+ * model A: form B renders against form A's data, silently, with no error and
101
+ * a fully functional form (Q9).
102
+ *
103
+ * The supported reuse shape is therefore a schema **function of the rules** -
104
+ * `const makeSchema = (rules) => schema<Model>(p => …)`, called per form -
105
+ * which keeps reuse while giving each form a factory bound to its own model.
106
+ *
107
+ * What one factory retains: **one private memo**, per factory, bounded by the
108
+ * union of keys the rules mention; and, per rule per `form()`, one
109
+ * `EvalContext` and one compiled callback. Nothing registers with a
110
+ * `DestroyRef` and there is no `destroy()` - it all becomes garbage with the
111
+ * form (S 3.6).
112
+ */
113
+ declare const createExpressionRules: <TModel extends object>(model: WritableSignal<TModel>, options?: ExpressionRuleOptions) => ExpressionRules;
114
+
115
+ /**
116
+ * The metadata key `evalText` writes through and a consumer reads back
117
+ * (plan S 1.2.8, S 3.5).
118
+ *
119
+ * `text` has no dedicated primitive in `@angular/forms/signals` the way
120
+ * `hidden` and `disabled` do, so it is `metadata(path, TEXT, logic)` against a
121
+ * key created once - which is Angular's own mechanism rather than a second one
122
+ * beside it, per the `/signals` reviewer checklist's item 4.
123
+ *
124
+ * **Created once, at module scope, deliberately.** `createMetadataKey` mints a
125
+ * fresh key per call, and the write and the read-back have to name the same
126
+ * object: a key re-created per access would let `metadata(p.x, TEXT, …)` write
127
+ * under one identity and `f.x().metadata(TEXT)` read under another, and the
128
+ * read would be `undefined` with nothing to show why. `text-key.spec.ts`
129
+ * asserts the two halves of that - `createMetadataKey` is per-call, and this
130
+ * is one of its results rather than the function itself.
131
+ */
132
+ declare const TEXT: _angular_forms_signals.MetadataKey<_angular_core.Signal<string | undefined>, string, string | undefined>;
133
+
134
+ export { TEXT, createExpressionRules };
135
+ export type { ExpressionRuleOptions, ExpressionRules };
@@ -80,15 +80,68 @@ declare const toText: (value: unknown) => string;
80
80
  * `'undefined'` would hand every consumer a silent blank for a bug in the
81
81
  * rule's own syntax. `createEvalSignal` already bypasses `onError` for it.
82
82
  *
83
- * **Only the type ships in this phase.** A matching `applyErrorPolicy` helper
84
- * would have no caller: on the `/reactive` path - the only path Phase 4 ships
85
- * - `createEvalSignal` applies the three cases internally. Its sole consumer
86
- * is the `/signals` adapter, which is on paper only (S 9), so the helper is
87
- * deferred to that phase and will be written against this type. Shipping an
88
- * unexercised code path is what S 9.1 declines to do for the scope-leak
89
- * containment, and the rule applies to both or to neither.
83
+ * **{@link applyErrorPolicy} ships beside this type as of Phase 6** (its
84
+ * S 3.4). Through Phase 4 only the type shipped, because the helper would have
85
+ * had no caller: on the `/reactive` path - the only path Phase 4 shipped -
86
+ * `createEvalSignal` applies the three cases internally, and shipping an
87
+ * unexercised code path is what S 9.1 declined to do for the scope-leak
88
+ * containment. The `/signals` adapter is that caller **from step 4**, where
89
+ * the registrars stop being stubs; `/reactive` does not use the helper at all.
90
90
  */
91
91
  type ExpressionErrorPolicy = 'throw' | 'undefined' | ((error: unknown) => unknown);
92
+ /**
93
+ * Runs one rule under one {@link ExpressionErrorPolicy}, with the one error
94
+ * that must never be routed through it taken out first.
95
+ *
96
+ * ```ts
97
+ * applyErrorPolicy(() => evaluateRule(compiled, context, options), onError)
98
+ * ```
99
+ *
100
+ * **`SignalContextWriteError` re-throws in every mode, including a handler
101
+ * function** (plan S 3.4, 1.2.11). On `/reactive` that bypass came free -
102
+ * `createEvalSignal` re-throws it regardless of `onError` (`eval-signal.ts:353`)
103
+ * - and that behaviour lives *inside* `createEvalSignal`, which the `/signals`
104
+ * path never calls. It does not travel with the type, so it is re-implemented
105
+ * here or it does not exist. An assigning expression is illegal on every
106
+ * recompute with every dataset, so under this module's default of
107
+ * `'undefined'` it would otherwise render as a permanently blank field with
108
+ * nothing in the console.
109
+ *
110
+ * **The `instanceof` needs the constructor, so the import of that class is a
111
+ * *value* import** - see the note above it, and the plan's revision 13, for
112
+ * why that form is protected by the compiler rather than by this spec.
113
+ * `evaluate-rule.spec.ts`'s write-error arm asserts on the **class** rather
114
+ * than on a message because what it covers is the bypass's *behaviour* across
115
+ * a real package boundary, which no compiler check reaches.
116
+ *
117
+ * **It lives in the core while `/signals`' choke point does not, and the pair
118
+ * is not inconsistent** (S 3.4.1). The discriminator is what the symbol grants
119
+ * a caller who reaches it: `evaluateRule` published *is* a second path to the
120
+ * walk, which S 9.1 requires there be only one of, while this is a pure
121
+ * function over an error and a policy that takes no context, holds no compiled
122
+ * callback and cannot reach a walk. Publishing it grants a caller nothing they
123
+ * could not write in four lines, and buys the thing duplication would lose -
124
+ * one implementation of the write-error bypass for both adapters, which cannot
125
+ * then drift into disagreeing about the one error that must never be
126
+ * swallowed.
127
+ *
128
+ * **It re-throws the error it caught; `/reactive` re-wraps.** `createEvalSignal`
129
+ * throws a *new* `SignalContextWriteError` carrying the offending expression
130
+ * string, which it has and this path does not - a `LogicFn` holds a compiled
131
+ * callback, not the source text. So the same misuse surfaces a different
132
+ * object at each entry point: wrapped with the expression under `/reactive`,
133
+ * the original here. Stated rather than hidden; it is the one thing a consumer
134
+ * catching this error across both adapters would notice.
135
+ *
136
+ * @param run - The rule invocation. On `/signals` this is the choke point,
137
+ * never a bare walk: containment is inside `run`, so a throw is
138
+ * contained before this function decides what to do about it.
139
+ * @param policy - Defaults to `'undefined'`, per {@link ExpressionErrorPolicy}.
140
+ * Resolving the default *here* is what makes an absent policy
141
+ * mean `'undefined'` rather than inheriting `eval-signals`'
142
+ * opposite one.
143
+ */
144
+ declare const applyErrorPolicy: <T>(run: () => T, policy?: ExpressionErrorPolicy) => T | undefined;
92
145
 
93
146
  /**
94
147
  * Composes one `EvalContext` for one field, out of two live sources.
@@ -135,5 +188,5 @@ type ExpressionErrorPolicy = 'throw' | 'undefined' | ((error: unknown) => unknow
135
188
  */
136
189
  declare const createFieldContext: (formSource: SignalContextSource, fieldSource: SignalContextSource, options?: EvalOptions) => EvalContext;
137
190
 
138
- export { createFieldContext, toText, toVisible };
191
+ export { applyErrorPolicy, createFieldContext, toText, toVisible };
139
192
  export type { ExpressionErrorPolicy };