@lotics/app-sdk 0.93.0 → 0.94.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.
@@ -334,11 +334,16 @@ export interface WorkflowResult<TData = unknown> {
334
334
  files?: UploadedFile[];
335
335
  data?: TData;
336
336
  /**
337
- * Per-input refusals the workflow returned via `return({ field_errors })`,
338
- * keyed by the INPUT name it declares — what a form wires straight onto the
339
- * control that is wrong (`<FormField error={result.field_errors?.ly_do}>`),
340
- * where `message` can only say it at the dialog's scope. Absent when the
341
- * workflow returned none.
337
+ * Per-input refusals, keyed by the INPUT name the alias declares — what a
338
+ * form wires straight onto the control that is wrong
339
+ * (`<FormField error={result.field_errors?.ly_do}>`), where `message` can
340
+ * only say it at the dialog's scope.
341
+ *
342
+ * Either half of the round trip fills it: the SERVER, when the payload does
343
+ * not match what the alias declares, and the workflow's own
344
+ * `return({ field_errors })`. One key, so a screen wires the control once
345
+ * rather than branching on which half refused. Absent when neither named a
346
+ * field — and an older server names none.
342
347
  */
343
348
  field_errors?: Record<string, string>;
344
349
  }
package/dist/src/rpc.js CHANGED
@@ -568,6 +568,34 @@ export function transportErrorMessage(status, parsed) {
568
568
  parsed.code.length > 0;
569
569
  return status < 500 || authored ? jsonMessage : gatewayErrorMessage(status);
570
570
  }
571
+ /**
572
+ * A failed request, carrying the per-input refusals when the API named any.
573
+ *
574
+ * A body that does not match what an alias declares is answered 400 with
575
+ * top-level `field_errors` — one sentence per INPUT name, which is what a form
576
+ * puts on the control that is wrong, where the message can only say it at the
577
+ * screen's scope. It rides the error because the transport's own callers are
578
+ * what turn a failure into the result an app reads. Optional throughout: a
579
+ * server that predates them sends none.
580
+ */
581
+ class ApiRefusal extends Error {
582
+ field_errors;
583
+ constructor(message, field_errors) {
584
+ super(message);
585
+ this.field_errors = field_errors;
586
+ }
587
+ }
588
+ /** The `field_errors` of an error body, when it carries a well-formed one. */
589
+ function readFieldErrors(parsed) {
590
+ if (parsed === null || typeof parsed !== "object" || !("field_errors" in parsed)) {
591
+ return undefined;
592
+ }
593
+ const raw = parsed.field_errors;
594
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
595
+ return undefined;
596
+ const named = Object.entries(raw).filter((entry) => typeof entry[1] === "string");
597
+ return named.length > 0 ? Object.fromEntries(named) : undefined;
598
+ }
571
599
  /**
572
600
  * The error a stream that never started should throw.
573
601
  *
@@ -663,8 +691,9 @@ async function apiCall(method, path, body, opts) {
663
691
  return new Promise(() => { });
664
692
  }
665
693
  // Never surface a non-JSON body (a gateway HTML error page) or a 5xx body as
666
- // the message — emit a body-free, status-derived message instead.
667
- throw new Error(transportErrorMessage(res.status, parsed));
694
+ // the message — emit a body-free, status-derived message instead. The
695
+ // per-input refusals ride along, for the caller that can place them.
696
+ throw new ApiRefusal(transportErrorMessage(res.status, parsed), readFieldErrors(parsed));
668
697
  }
669
698
  return parsed ?? (text ? text : {});
670
699
  }
@@ -793,7 +822,16 @@ async function standaloneWorkflow(p) {
793
822
  // rejection carrying a raw body) so an app reads `result.status === "error"`
794
823
  // uniformly with a handled workflow error. `apiCall` already sanitized the
795
824
  // message, so it never contains an HTML body.
796
- return { status: "error", message: err instanceof Error ? err.message : "The workflow failed to run." };
825
+ //
826
+ // A payload the alias refuses is that same shape plus the inputs it named,
827
+ // which is the half a form can act on — the same key a workflow's own
828
+ // `return({ field_errors })` arrives under, so a screen wires one control
829
+ // once whichever half answered.
830
+ return {
831
+ status: "error",
832
+ message: err instanceof Error ? err.message : "The workflow failed to run.",
833
+ ...(err instanceof ApiRefusal && err.field_errors ? { field_errors: err.field_errors } : {}),
834
+ };
797
835
  }
798
836
  }
799
837
  async function standaloneAgentRuns(p) {
package/docs/mutations.md CHANGED
@@ -65,7 +65,7 @@ Every call resolves to a `WorkflowResult<TData>` (type exported from the package
65
65
  | `message` | `string?` | The workflow's `return({ message })` text, a validation/binding error, or a body-free transport message |
66
66
  | `files` | `UploadedFile[]?` | Files generated during the run (auto-collected — below). Absent when the run generated none |
67
67
  | `data` | `TData?` | Structured data from `return({ data })`. Absent when no return step ran |
68
- | `field_errors` | `Record<string, string>?` | Per-input refusals from `return({ field_errors })`, keyed by the INPUT name the alias declares. Absent when the workflow returned none |
68
+ | `field_errors` | `Record<string, string>?` | Per-input refusals, keyed by the INPUT name the alias declares — from the server when the payload does not match what the alias declares, and from the workflow's own `return({ field_errors })`. Absent when neither named a field |
69
69
 
70
70
  ### The failure model: check `status`, never just try/catch
71
71
 
@@ -111,8 +111,10 @@ passes through unvalidated).
111
111
 
112
112
  ### Locating a refusal: `field_errors`
113
113
 
114
- A workflow that validates its own inputs refuses with both halves — a message for the dialog and a
115
- map for the controls:
114
+ Two things refuse a call, and both address the input they refused. A payload that does not match
115
+ what the alias declares — a missing required input, a value of the wrong type — is refused before
116
+ the workflow runs, with one sentence per input name. A workflow that validates its own inputs
117
+ refuses the same way, with both halves — a message for the dialog and a map for the controls:
116
118
 
117
119
  ```ts
118
120
  return({ status: "error", message: "Thiếu lý do.",
@@ -130,7 +132,8 @@ setErrs(result.field_errors ?? {});
130
132
  ```
131
133
 
132
134
  Keep `message` too — it is what a refusal with no field to blame says, and the two are shown
133
- together (a `Callout` at the dialog's scope plus the per-field text).
135
+ together (a `Callout` at the dialog's scope plus the per-field text). One reading covers both
136
+ refusals: the key is the same whichever half answered, so a form never branches on that.
134
137
 
135
138
  ### Generated files come back in `files[]`
136
139
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.93.0",
3
+ "version": "0.94.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps \u2014 typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {