@shivaedev/form 0.6.2 → 0.7.1

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +132 -120
  3. package/package.json +9 -9
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.1 - 2026-10-30
4
+
5
+ ### Changed
6
+
7
+ - Require Effect 4.0.6 and align the Effect companion package peers and generated starter catalog to the same exact release. Install the 4.0.6 Effect stack together.
8
+
9
+ ### Fixed
10
+
11
+ - Register hosted RPC cache-control handling through Effect 4.0.6's request-scoped pre-response API so responses keep `private, no-store`.
12
+ - Accept native RPC's `application/x-ndjson` media type while preserving `application/ndjson` callers at the transport guard.
13
+ - Keep subscriptions open after their HTTP request body completes; closing the socket still cancels them.
14
+
15
+ ## 0.7.0 - 2026-10-27
16
+
17
+ ### Breaking
18
+
19
+ - Rename `@shivaedev/effect-form` to `@shivaedev/form`. Change dependency declarations and imports from `@shivaedev/effect-form/...` to `@shivaedev/form/...`; module paths inside the package are unchanged.
20
+
3
21
  ## 0.6.2 - 2026-10-24
4
22
 
5
23
  ### Changed
package/README.md CHANGED
@@ -8,9 +8,9 @@ An input gives you a string; your service needs a number. Meanwhile, fresh serve
8
8
 
9
9
  ```ts
10
10
  const order = form(Schema.Struct({ name: Schema.String, quantity: Schema.NumberFromString }), {
11
- initialValues: { name: "Paper", quantity: "300" },
12
- onSubmit: Effect.succeed,
13
- runtime,
11
+ initialValues: { name: "Paper", quantity: "300" },
12
+ onSubmit: Effect.succeed,
13
+ runtime,
14
14
  });
15
15
 
16
16
  order.change("name", "Copy paper");
@@ -45,32 +45,27 @@ import { form } from "@shivaedev/form/form.ts";
45
45
 
46
46
  const runtime = Atom.runtime(Layer.empty);
47
47
  const fields = Schema.Struct({
48
- name: Schema.NonEmptyString,
49
- quantity: Schema.NumberFromString,
48
+ name: Schema.NonEmptyString,
49
+ quantity: Schema.NumberFromString,
50
50
  });
51
51
 
52
52
  const order = form(fields, {
53
- initialValues: { name: "Paper", quantity: "300" },
54
- onSubmit: (values, submitter) =>
55
- values.name === "reserved"
56
- ? submitter.fail("name", "Choose another name")
57
- : Effect.succeed(values),
58
- runtime,
53
+ initialValues: { name: "Paper", quantity: "300" },
54
+ onSubmit: (values, submitter) => (values.name === "reserved" ? submitter.fail("name", "Choose another name") : Effect.succeed(values)),
55
+ runtime,
59
56
  });
60
57
 
61
58
  const registry = AtomRegistry.make();
62
59
  const release = registry.mount(order.dirty);
63
60
 
64
61
  try {
65
- order.change("quantity", "350");
66
- registry.set(order.submit, undefined);
67
- const saved = await Effect.runPromise(
68
- AtomRegistry.getResult(registry, order.submit, { suspendOnWaiting: true }),
69
- );
70
- console.log(saved);
62
+ order.change("quantity", "350");
63
+ registry.set(order.submit, undefined);
64
+ const saved = await Effect.runPromise(AtomRegistry.getResult(registry, order.submit, { suspendOnWaiting: true }));
65
+ console.log(saved);
71
66
  } finally {
72
- release();
73
- registry.dispose();
67
+ release();
68
+ registry.dispose();
74
69
  }
75
70
  ```
76
71
 
@@ -94,40 +89,54 @@ const runtime = Atom.runtime(Layer.empty);
94
89
  const fields = Schema.Struct({ name: Schema.NonEmptyString, quantity: Schema.NumberFromString });
95
90
 
96
91
  function OrderEditor() {
97
- const [order] = useState(() => form(fields, {
98
- initialValues: { name: "Paper", quantity: "300" },
99
- onSubmit: Effect.succeed,
100
- runtime,
101
- }));
102
- const name = useField(order, "name");
103
- const quantity = useField(order, "quantity");
104
- const submit = useSubmit(order);
105
- const dirty = useDirty(order);
106
-
107
- return <form onSubmit={(event) => {
108
- event.preventDefault();
109
- if (!submit.submitting) submit.run();
110
- }}>
111
- <label>Name <input value={name.value} onBlur={name.onBlur}
112
- onChange={(event) => name.onChange(event.target.value)} /></label>
113
- {name.error && <p role="alert">{name.error}</p>}
114
- <label>Quantity <input value={quantity.value} onBlur={quantity.onBlur}
115
- onChange={(event) => quantity.onChange(event.target.value)} /></label>
116
- {quantity.error && <p role="alert">{quantity.error}</p>}
117
- <button disabled={submit.submitting}>Save</button>
118
- <button type="button" onClick={order.revert}>Revert</button>
119
- <p role="status">{submit.submitting ? "Saving…" : dirty ? "Unsaved changes" : "Saved"}</p>
120
- </form>;
92
+ const [order] = useState(() =>
93
+ form(fields, {
94
+ initialValues: { name: "Paper", quantity: "300" },
95
+ onSubmit: Effect.succeed,
96
+ runtime,
97
+ }),
98
+ );
99
+ const name = useField(order, "name");
100
+ const quantity = useField(order, "quantity");
101
+ const submit = useSubmit(order);
102
+ const dirty = useDirty(order);
103
+
104
+ return (
105
+ <form
106
+ onSubmit={(event) => {
107
+ event.preventDefault();
108
+ if (!submit.submitting) submit.run();
109
+ }}
110
+ >
111
+ <label>
112
+ Name <input value={name.value} onBlur={name.onBlur} onChange={(event) => name.onChange(event.target.value)} />
113
+ </label>
114
+ {name.error && <p role="alert">{name.error}</p>}
115
+ <label>
116
+ Quantity <input value={quantity.value} onBlur={quantity.onBlur} onChange={(event) => quantity.onChange(event.target.value)} />
117
+ </label>
118
+ {quantity.error && <p role="alert">{quantity.error}</p>}
119
+ <button disabled={submit.submitting}>Save</button>
120
+ <button type="button" onClick={order.revert}>
121
+ Revert
122
+ </button>
123
+ <p role="status">{submit.submitting ? "Saving…" : dirty ? "Unsaved changes" : "Saved"}</p>
124
+ </form>
125
+ );
121
126
  }
122
127
 
123
128
  export function mountOrderEditor(container: HTMLElement) {
124
- const registry = AtomRegistry.make();
125
- const root = createRoot(container);
126
- root.render(<RegistryContext.Provider value={registry}><OrderEditor /></RegistryContext.Provider>);
127
- return () => {
128
- root.unmount();
129
- registry.dispose();
130
- };
129
+ const registry = AtomRegistry.make();
130
+ const root = createRoot(container);
131
+ root.render(
132
+ <RegistryContext.Provider value={registry}>
133
+ <OrderEditor />
134
+ </RegistryContext.Provider>,
135
+ );
136
+ return () => {
137
+ root.unmount();
138
+ registry.dispose();
139
+ };
131
140
  }
132
141
  ```
133
142
 
@@ -139,16 +148,16 @@ Render general submission failures from `submit.result` in the application's own
139
148
 
140
149
  Call `receive` with encoded values from a refreshed or saved row. Encode domain values before receiving them; for a numeric string field, receive `String(row.quantity)`.
141
150
 
142
- | Situation | What the form does |
143
- | --- | --- |
144
- | A field still equals its baseline | Receives the new value. |
145
- | A field was edited locally | Keeps that input; received values become the new baseline. |
146
- | A refresh omits an untouched optional key | Removes the key. |
147
- | An optional key was added locally | Keeps it when a refresh omits it; revert restores the received values. |
148
- | The handler succeeds | Accepts the submitted encoded values without replacing later edits. |
151
+ | Situation | What the form does |
152
+ | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
153
+ | A field still equals its baseline | Receives the new value. |
154
+ | A field was edited locally | Keeps that input; received values become the new baseline. |
155
+ | A refresh omits an untouched optional key | Removes the key. |
156
+ | An optional key was added locally | Keeps it when a refresh omits it; revert restores the received values. |
157
+ | The handler succeeds | Accepts the submitted encoded values without replacing later edits. |
149
158
  | A refresh arrived during a successful save | Keeps that refresh as the baseline; values still equal to the accepted submission count as saved and adopt the next refresh. |
150
- | The handler fails | Keeps input; an intervening refresh remains the baseline. |
151
- | `revert()` is called | Restores the latest baseline, including omitted optional keys. |
159
+ | The handler fails | Keeps input; an intervening refresh remains the baseline. |
160
+ | `revert()` is called | Restores the latest baseline, including omitted optional keys. |
152
161
 
153
162
  The form does not replace submitted encoded input with its decoded representation. A trimmed name can be accepted as saved while the input still contains its spaces. Receive the normalized saved row when the application wants that representation on screen. `useEditor` from `react` performs that composition.
154
163
 
@@ -160,10 +169,10 @@ For a core form, supply the storage provider explicitly:
160
169
 
161
170
  ```ts
162
171
  const order = form(fields, {
163
- initialValues: { name: "Paper", quantity: "300" },
164
- onSubmit: Effect.succeed,
165
- persistedDraft: { key: "order:123", storage: () => window.localStorage },
166
- runtime,
172
+ initialValues: { name: "Paper", quantity: "300" },
173
+ onSubmit: Effect.succeed,
174
+ persistedDraft: { key: "order:123", storage: () => window.localStorage },
175
+ runtime,
167
176
  });
168
177
  ```
169
178
 
@@ -174,14 +183,16 @@ import { useState } from "react";
174
183
  import { usePersistedDraft } from "@shivaedev/form/client/react.ts";
175
184
 
176
185
  function OrderEditor() {
177
- const persistedDraft = usePersistedDraft({ key: "order:123" });
178
- const [order] = useState(() => form(fields, {
179
- initialValues: { name: "Paper", quantity: "300" },
180
- onSubmit: Effect.succeed,
181
- persistedDraft,
182
- runtime,
183
- }));
184
- return <OrderFields form={form} />;
186
+ const persistedDraft = usePersistedDraft({ key: "order:123" });
187
+ const [order] = useState(() =>
188
+ form(fields, {
189
+ initialValues: { name: "Paper", quantity: "300" },
190
+ onSubmit: Effect.succeed,
191
+ persistedDraft,
192
+ runtime,
193
+ }),
194
+ );
195
+ return <OrderFields form={order} />;
185
196
  }
186
197
  ```
187
198
 
@@ -201,13 +212,13 @@ For `Schema.NonEmptyString`, an empty input's message is `Required`. Longer mini
201
212
 
202
213
  ```ts
203
214
  const profile = form(Schema.Struct({ name: Schema.String }), {
204
- checks: {
205
- name: (value) => Effect.succeed(value === "taken" ? "Name is taken" : undefined),
206
- },
207
- debounce: "300 millis",
208
- initialValues: { name: "" },
209
- onSubmit: Effect.succeed,
210
- runtime,
215
+ checks: {
216
+ name: (value) => Effect.succeed(value === "taken" ? "Name is taken" : undefined),
217
+ },
218
+ debounce: "300 millis",
219
+ initialValues: { name: "" },
220
+ onSubmit: Effect.succeed,
221
+ runtime,
211
222
  });
212
223
  ```
213
224
 
@@ -220,15 +231,17 @@ For a nested value held in one top-level field, `errorAt(["answers", index, "tex
220
231
  Pass a path tuple to `submitter.fail` to reject one element or a property inside it. Paths use indices in the submitted encoded snapshot. Optional `arrayKeys` selectors receive encoded elements of top-level array fields and return their stable, unique keys:
221
232
 
222
233
  ```ts
223
- const answers = form(Schema.Struct({
224
- answers: Schema.Array(Schema.Struct({ id: Schema.String, text: Schema.NonEmptyString })),
225
- }), {
226
- arrayKeys: { answers: answer => answer.id },
227
- initialValues: { answers: [{ id: "question", text: "Yes" }] },
228
- onSubmit: (_values, submitter) =>
229
- submitter.fail(["answers", 0, "text"], "This answer is closed"),
230
- runtime,
231
- });
234
+ const answers = form(
235
+ Schema.Struct({
236
+ answers: Schema.Array(Schema.Struct({ id: Schema.String, text: Schema.NonEmptyString })),
237
+ }),
238
+ {
239
+ arrayKeys: { answers: (answer) => answer.id },
240
+ initialValues: { answers: [{ id: "question", text: "Yes" }] },
241
+ onSubmit: (_values, submitter) => submitter.fail(["answers", 0, "text"], "This answer is closed"),
242
+ runtime,
243
+ },
244
+ );
232
245
  ```
233
246
 
234
247
  `FieldFailure.path` keeps the top-level field name as a string. For a tuple rejection, optional `FieldFailure.segments` contains its submitted path.
@@ -244,8 +257,8 @@ import { Schema } from "effect";
244
257
  import { emptyAsNull } from "@shivaedev/form/emptyAsNull.ts";
245
258
 
246
259
  const fields = Schema.Struct({
247
- visibility: Schema.Literals(["private", "public"]),
248
- quantity: emptyAsNull(Schema.NumberFromString),
260
+ visibility: Schema.Literals(["private", "public"]),
261
+ quantity: emptyAsNull(Schema.NumberFromString),
249
262
  });
250
263
  ```
251
264
 
@@ -255,42 +268,42 @@ For a form made with these fields, `order.choices("visibility")` returns the enc
255
268
 
256
269
  Import the module that defines a name; there is no root entry.
257
270
 
258
- | Module | Application-facing exports |
259
- | --- | --- |
260
- | `@shivaedev/form/form.ts` | `form(schema, config)` |
261
- | `@shivaedev/form/shape.ts` | `Form`, `Submitter`, `FieldFailure`, `Invalid` and the field types they use |
271
+ | Module | Application-facing exports |
272
+ | --------------------------------- | ------------------------------------------------------------------------------- |
273
+ | `@shivaedev/form/form.ts` | `form(schema, config)` |
274
+ | `@shivaedev/form/shape.ts` | `Form`, `Submitter`, `FieldFailure`, `Invalid` and the field types they use |
262
275
  | `@shivaedev/form/client/react.ts` | `useField`, `useSubmit`, `useDirty`, `usePersistedDraft`, `DraftSessionContext` |
263
- | `@shivaedev/form/draftSession.ts` | `draftSession(id)` |
264
- | `@shivaedev/form/emptyAsNull.ts` | `emptyAsNull` |
276
+ | `@shivaedev/form/draftSession.ts` | `draftSession(id)` |
277
+ | `@shivaedev/form/emptyAsNull.ts` | `emptyAsNull` |
265
278
 
266
279
  `form` takes a `Schema.Struct` and this configuration:
267
280
 
268
- | Option | Type or role |
269
- | --- | --- |
270
- | `initialValues` | `Encoded<F>` |
271
- | `runtime` | Atom runtime for the handler/check services and schema decoding services |
272
- | `onSubmit` | `(values, submitter, submitted) => Effect<A, E, R>`; the three arguments are decoded values, field failure helper and encoded snapshot |
273
- | `checks` | Optional functions keyed by field, from encoded input to an Effect of `string \| undefined` |
274
- | `arrayKeys` | Optional `ArrayKeys<F>` selectors keyed by top-level array field, from encoded element to stable `PropertyKey`; retains nested feedback across reordering |
275
- | `debounce` | Optional Effect `Duration.Input` for checks |
276
- | `persistedDraft` | Optional `{ key, storage, maxBytes?, maxAgeMs?, session? }`; React's `usePersistedDraft` supplies storage and session ownership |
281
+ | Option | Type or role |
282
+ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
283
+ | `initialValues` | `Encoded<F>` |
284
+ | `runtime` | Atom runtime for the handler/check services and schema decoding services |
285
+ | `onSubmit` | `(values, submitter, submitted) => Effect<A, E, R>`; the three arguments are decoded values, field failure helper and encoded snapshot |
286
+ | `checks` | Optional functions keyed by field, from encoded input to an Effect of `string \| undefined` |
287
+ | `arrayKeys` | Optional `ArrayKeys<F>` selectors keyed by top-level array field, from encoded element to stable `PropertyKey`; retains nested feedback across reordering |
288
+ | `debounce` | Optional Effect `Duration.Input` for checks |
289
+ | `persistedDraft` | Optional `{ key, storage, maxBytes?, maxAgeMs?, session? }`; React's `usePersistedDraft` supplies storage and session ownership |
277
290
 
278
291
  The returned form exposes:
279
292
 
280
- | Member | Type or use |
281
- | --- | --- |
282
- | `values` | `AtomRef<Encoded<F>>` for the whole input |
283
- | `field(name)` | `AtomRef<Encoded<F>[K]>` for a field |
284
- | `change(name, value)` | Write encoded input through the form's editing operation |
285
- | `blur(name)` | Field blur operation, supplied as `useField(...).onBlur` |
286
- | `error(name)` | Atom of `string \| undefined` |
293
+ | Member | Type or use |
294
+ | -------------------------- | --------------------------------------------------------------------------------------------- |
295
+ | `values` | `AtomRef<Encoded<F>>` for the whole input |
296
+ | `field(name)` | `AtomRef<Encoded<F>[K]>` for a field |
297
+ | `change(name, value)` | Write encoded input through the form's editing operation |
298
+ | `blur(name)` | Field blur operation, supplied as `useField(...).onBlur` |
299
+ | `error(name)` | Atom of `string \| undefined` |
287
300
  | `errorAt([name, ...path])` | Atom of nested Schema feedback and handler rejections; visibility follows the top-level field |
288
- | `choices(name)` | Encoded choices or `undefined` |
289
- | `dirty` | Atom of `boolean` |
290
- | `receive(values)` | Merge received encoded values |
291
- | `revert()` | Restore the baseline |
292
- | `submit` | `AtomResultFn<void, A, E \| ER \| FieldFailure \| Invalid>` |
293
- | `submitting` | Atom of `boolean` for submission waiting state |
301
+ | `choices(name)` | Encoded choices or `undefined` |
302
+ | `dirty` | Atom of `boolean` |
303
+ | `receive(values)` | Merge received encoded values |
304
+ | `revert()` | Restore the baseline |
305
+ | `submit` | `AtomResultFn<void, A, E \| ER \| FieldFailure \| Invalid>` |
306
+ | `submitting` | Atom of `boolean` for submission waiting state |
294
307
 
295
308
  `F` is the schema's fields, `A` and `E` are the handler's success and failure types, and `ER` is the atom runtime's failure type. `FieldFailure` contains the top-level string `path`, `message` and optional submitted path `segments`; `Invalid` contains `messages`, a `FieldMessages` record.
296
309
 
@@ -299,17 +312,16 @@ The lower-level `draft.ts` (`draft`), `refs.ts` (`fromRef`, `propertyRef`) and `
299
312
  ### Install and limits
300
313
 
301
314
  ```sh
302
- pnpm add @shivaedev/form effect@4.0.5
315
+ pnpm add @shivaedev/form effect
303
316
  ```
304
317
 
305
318
  For React bindings:
306
319
 
307
320
  ```sh
308
- pnpm add @effect/atom-react@4.0.5 react@19.3.0
321
+ pnpm add @effect/atom-react react
309
322
  ```
310
323
 
311
324
  The React mounting example also uses the application's `react-dom`. React and `@effect/atom-react` are optional peers; the core modules do not import them. The package needs Node 24 or newer.
312
-
313
325
  - Field names are top-level schema keys. There is no path-addressed field-array API.
314
326
  - Replace encoded values rather than mutating objects in place; use `change` for ordinary UI edits.
315
327
  - Keep the form and registry for their intended session, and clean up the registry when that session ends.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/form",
3
- "version": "0.6.2",
3
+ "version": "0.7.1",
4
4
  "description": "Schema-derived Effect atom forms with optional React hooks",
5
5
  "keywords": [
6
6
  "effect"
@@ -43,21 +43,21 @@
43
43
  "README.md"
44
44
  ],
45
45
  "devDependencies": {
46
- "@effect/atom-react": "4.0.5",
47
- "@effect/vitest": "4.0.5",
48
- "@shivaedev/quality": "0.41.1",
49
- "@shivaedev/test": "0.9.0",
50
- "@shivaedev/test-story": "0.20.1",
46
+ "@effect/atom-react": "4.0.6",
47
+ "@effect/vitest": "4.0.6",
48
+ "@shivaedev/quality": "0.45.0",
49
+ "@shivaedev/test": "0.10.1",
50
+ "@shivaedev/test-story": "0.24.0",
51
51
  "@types/node": "24.19.2",
52
52
  "@types/react": "19.3.0",
53
- "effect": "4.0.5",
53
+ "effect": "4.0.6",
54
54
  "react": "19.3.1",
55
55
  "typescript": "7.0.2",
56
56
  "vitest": "5.0.5"
57
57
  },
58
58
  "peerDependencies": {
59
- "@effect/atom-react": "4.0.5",
60
- "effect": "4.0.5",
59
+ "@effect/atom-react": "4.0.6",
60
+ "effect": "4.0.6",
61
61
  "react": "19.3.1"
62
62
  },
63
63
  "peerDependenciesMeta": {