svelte-specma 1.1.6 → 2.0.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 CHANGED
@@ -1,298 +1,968 @@
1
1
  # Svelte-Specma
2
2
 
3
- Svelte-Specma is a Svelte store used to do client-side validation using the very powerful [Specma](https://www.npmjs.com/package/specma) library.
4
-
5
- - Collection specs defined with the exact **same shape as the value**!
6
- - Based on **predicate functions** of type `true || "reason"`
7
- - **Composable specs** with intuitive `and`/`or` operators
8
- - Easy **cross-validation** between multiple fields
9
- - User defined **customized invalid reasons**
10
- - **Async validation** out of the box
11
- - Very **small footprint**
12
-
13
- # Design considerations
3
+ Svelte-Specma connects Specma predicate specs to Svelte stores to provide small, composable, and predictable client-side validation for forms and arbitrary state. It supports nested/collection shapes, synchronous and asynchronous predicates, custom error messages and easy binding to HTML inputs.
4
+
5
+ Goals
6
+
7
+ - Minimal API that maps Specma specs to reactive Svelte stores
8
+ - Compose nested validators for objects, arrays and Maps
9
+ - Allow cross-field and async validation via Specma
10
+ - Small, framework-friendly building blocks (stores + a Svelte action)
11
+ - **Share validation specs between client and server** — define once, use everywhere
12
+
13
+ ## Table of Contents
14
+
15
+ - [Svelte-Specma](#svelte-specma)
16
+ - [Table of Contents](#table-of-contents)
17
+ - [Installation](#installation)
18
+ - [Quick setup](#quick-setup)
19
+ - [Concepts in two lines](#concepts-in-two-lines)
20
+ - [Create a store (convenience)](#create-a-store-convenience)
21
+ - [Primitive example (single field)](#primitive-example-single-field)
22
+ - [Collection example (form with nested list)](#collection-example-form-with-nested-list)
23
+ - [Svelte input binding (register)](#svelte-input-binding-register)
24
+ - [Shared Specs (Client \& Server)](#shared-specs-client--server)
25
+ - [Basic Pattern](#basic-pattern)
26
+ - [Client-Side Usage (Svelte)](#client-side-usage-svelte)
27
+ - [Server-Side Usage (Node.js/SvelteKit)](#server-side-usage-nodejssveltekit)
28
+ - [Complex Nested Specs](#complex-nested-specs)
29
+ - [Benefits of Shared Specs](#benefits-of-shared-specs)
30
+ - [API summary](#api-summary)
31
+ - [Real-world patterns](#real-world-patterns)
32
+ - [Tips \& best practices](#tips--best-practices)
33
+ - [Comprehensive Examples](#comprehensive-examples)
34
+ - [Example 1: Simple Login Form with Email and Password Validation](#example-1-simple-login-form-with-email-and-password-validation)
35
+ - [Example 2: User Profile Form with Nested Validation](#example-2-user-profile-form-with-nested-validation)
36
+ - [Example 3: Dynamic List Management (Shopping Cart)](#example-3-dynamic-list-management-shopping-cart)
37
+ - [Example 4: Async Validation (Username Availability Check)](#example-4-async-validation-username-availability-check)
38
+ - [Example 5: Shared Validation (Full Stack)](#example-5-shared-validation-full-stack)
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ npm install svelte-specma
44
+ # or
45
+ yarn add svelte-specma
46
+ ```
14
47
 
15
- ## Spec design
48
+ ## Quick setup
16
49
 
17
- You should first get familiar with the Specma way of [defining a spec](https://davidsavoie1.github.io/specma/#/gettingStarted?id=predicate-spec). All valid Specma specs will work fine with Svelte-Specma, including async validation, spec composition and cross-fields validation with `getFrom` function as second predicate argument. Specs can hence be reused in a variety of contexts without redefining them all the time.
50
+ Svelte-Specma delegates predicate operations to a Specma-compatible implementation. Configure the library once at app startup:
18
51
 
19
- Simple composition with possibily nested `and` and `or` allows adding advanced constraints on a base spec, such as calling a remote endpoint asynchronously to check a value availability.
52
+ ```js
53
+ import * as specma from "specma"; // or another Specma-compatible lib
54
+ import { configure } from "svelte-specma";
20
55
 
21
- ## Requiredness
56
+ configure(specma);
57
+ ```
22
58
 
23
- By design, as in Specma, the requiredness of certain fields is not defined in the spec itself. It is rather is defined at store creation time by passing in a `required` object of the same shape as the value. This allows reusing the same spec in different forms, where only the required fields change, not the predicates themselves.
59
+ This must run before creating any specable stores.
24
60
 
25
- Values that are `undefined` won't be validated against their spec unless they are specified as required. Also, because Svelte-Specma is mainly used in forms, `null` and `""` are considered as missing when a value is required.
61
+ ## Concepts in two lines
26
62
 
27
- ## Svelte integration
63
+ - `predSpecable`: single-value store that validates a primitive/non-collection value.
64
+ - `collSpecable`: collection-aware store (object / array / Map) that composes child specable stores.
28
65
 
29
- Svelte-Specma uses Svelte stores to manage state and validation. It is not tied to any particular component, although it might make sense for you to eventually design components to reduce boilerplate code (updating store value, activating on input blur, displaying loading or error messages, etc.).
66
+ ## Create a store (convenience)
30
67
 
31
- # `configure` - Before using!
68
+ Use `specable()` it chooses `predSpecable` or `collSpecable` automatically.
32
69
 
33
- Since Specma relies on some Javascript Symbols to define specs, Svelte-Specma must use the same instance of the Spec library. **It must hence be configured once prior to usage**.
70
+ ### Primitive example (single field)
34
71
 
35
72
  ```js
36
- import * as specma from "specma";
37
- import { configure } from "svelte-specma";
38
-
39
- configure(specma);
40
- ```
73
+ import { specable } from "svelte-specma";
41
74
 
42
- # `specable`
75
+ const age = specable(0, {
76
+ id: "age",
77
+ required: true,
78
+ spec: (v) =>
79
+ typeof v === "number" && v >= 0 ? true : "must be a non-negative number",
80
+ });
43
81
 
44
- Main entry point and **preferred way for defining a specable store**. Will dispatch to `collSpecable` or `predSpecable` based on its arguments (based on spec's shape first, then initial value's shape) to eventually produce a deeply nested store.
82
+ // subscribe for UI binding:
83
+ age.subscribe((s) => {
84
+ // s.value, s.valid, s.validating, s.error, s.changed, s.active, s.promise, ...
85
+ console.log(s);
86
+ });
87
+ ```
45
88
 
46
- ## Inputs
89
+ ### Collection example (form with nested list)
47
90
 
48
91
  ```js
49
- const store = specable(initialValue, { spec, ...rest });
50
- ```
92
+ import { specable } from "svelte-specma";
93
+ import { spread } from "specma"; // example usage of Specma helpers
51
94
 
52
- - `initialValue`: Any. The initial value to validate against the spec.
95
+ const productSpec = {
96
+ name: (v = "") => (v.length ? true : "required"),
97
+ price: (v) => (typeof v === "number" && v >= 0 ? true : "invalid price"),
98
+ };
99
+
100
+ const catalog = specable(
101
+ { title: "My shop", items: [{ id: "1", name: "Pen", price: 1.5 }] },
102
+ {
103
+ spec: { title: (v) => true, items: spread(productSpec) },
104
+ getId: { items: (item) => item.id }, // id strategy for collection children
105
+ required: { title: 1, items: spread({ name: 1 }) },
106
+ }
107
+ );
53
108
 
54
- - `spec`: Any. The Specma spec to validate against.
109
+ // add an item programmatically:
110
+ catalog.add([{ id: "2", name: "", price: null }]);
111
+ ```
55
112
 
56
- - `...rest`: Object. See `collSpecable` and `predSpecable` details below.
113
+ ## Svelte input binding (register)
57
114
 
58
- ## Output
115
+ Use the `register` action to bind a `predSpecable` to an input with optional converters:
59
116
 
60
- Either a [`collSpecable`](#collSpecable) or [`predSpecable`](#predSpecable) store (see below).
117
+ Basic usage:
61
118
 
62
- # `predSpecable`
119
+ ```svelte
120
+ <script>
121
+ import { specable, register } from "svelte-specma";
122
+ const name = specable("", { id: "name", spec: (v) => v ? true : "required" });
123
+ </script>
63
124
 
64
- A Svelte store used to validate a value against the predicate function part of a Specma spec.
125
+ <input use:register="{name}" />
126
+ ```
65
127
 
66
- ```js
67
- /* Will create a `predSpecable` store, since initial value is not a collection */
68
- const store = specable(
69
- 30, // Initial value
70
- {
71
- spec: (v) => v === 42 || "is not the answer",
72
- required: false,
73
- id: "someId",
74
- }
75
- );
128
+ With converters (e.g. numeric input):
76
129
 
77
- /* Static properties and methods */
78
- const { id, isRequired, spec, activate, reset, set, subscribe } = store;
130
+ ```svelte
131
+ <script>
132
+ const age = specable(0, { id: "age", spec: v => typeof v === 'number' || "must be a number" });
133
+ const conv = { toInput: (v) => v == null ? "" : String(v), toValue: (s) => s === "" ? undefined : Number(s) };
134
+ </script>
79
135
 
80
- /* Subscription state values */
81
- $: ({ value, active, changed, valid, validating, error, promise, id } = $store);
136
+ <input use:register="{[age, conv]}" inputmode="numeric" />
82
137
  ```
83
138
 
84
- ## Inputs
139
+ ## Shared Specs (Client & Server)
85
140
 
86
- - `initialValue`: Any. The initial value to validate, saved in internal state
87
- - `spec`: Any. The Specma spec. Only the predicate function part of the spec is used in a `predSpecable`, so a compound value (object, array, etc.) won't validate its children spec. To do so, use a `collSpecable` instead.
88
- - `required`: Boolean. If a value is required, must be different than `undefined`, `null` or `""`; will return the Specma `isRequired` message otherwise. False by default.
89
- - `changePred`: Function. `(a, b) => boolean`. Function to evaluate if value is different from initial value. By default, will do a deep object value equality and compare dates by value.
90
- - `id`: Any. Used to uniquely identify the store. Mainly useful for values part of a list.
141
+ One of the key advantages of using Specma with Svelte-Specma is the ability to **define validation specs once and reuse them across both client and server**. This ensures consistency, reduces duplication, and makes your codebase more maintainable.
91
142
 
92
- ## Outputs
143
+ ### Basic Pattern
93
144
 
94
- - `id`: Any. A pass-through of the store's creation `id`.
145
+ Define your specs in a shared module that can be imported by both client and server code:
95
146
 
96
- - `isRequired`: Boolean. Is the store value required? Based on the `required` creation argument.
147
+ ```js
148
+ // filepath: shared/specs/userSpecs.js
149
+ // Shared validation specs - works in both browser and Node.js
97
150
 
98
- - `spec`: The predicate function used to derive a validation result.
151
+ export const emailSpec = (v) => {
152
+ const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
153
+ return emailPattern.test(v) || "Invalid email address";
154
+ };
99
155
 
100
- - `activate`: Async function. `(Boolean = true) => Promise`. Method to de/activate the store validation. If set to true, will immediately trigger validation and return a promise that resolves to the `valid` result property.
156
+ export const passwordSpec = (v) =>
157
+ v.length >= 8 ? true : "Password must be at least 8 characters";
101
158
 
102
- - `reset`: Function. `(Any = initialValue) => undefined`. Reset the store to a new initial value (or the initial one if no argument) and deactivate the store.
159
+ export const usernameSpec = (v) =>
160
+ v && v.length >= 3 ? true : "Username must be at least 3 characters";
103
161
 
104
- - `set`: Function. `(Any, shouldActivate = false) => undefined`. Set the store's internal value to a new one. Will trigger validation if store is active. If `shouldActivate = true`, activates the store after setting the value.
162
+ export const ageSpec = (v) =>
163
+ typeof v === "number" && v >= 18 && v <= 120
164
+ ? true
165
+ : "Age must be between 18 and 120";
105
166
 
106
- - `submit`: Function. `(value) => {}`. Method to first activate then handle the current value. `submitting` state is set to `true` during submit.
167
+ export const phoneSpec = (v) => /^\d{10}$/.test(v) || "Phone must be 10 digits";
168
+ ```
107
169
 
108
- - `subscribe`: Function. `(fn) => unsubscribe`. The Svelte store's subscribe method. Usually used in Svelte components with a reactive autosubscription declaration (`$: storeState = $store`). The function argument will be called with the updated internal state (see below) each time it changes. Returns an unsubscribe function that can be called when component unmounts to prevent memory leaks.
170
+ ### Client-Side Usage (Svelte)
109
171
 
110
- - `value`: Any. The internal state value, which can be modified with the store's methods.
172
+ ```svelte
173
+ <!-- filepath: src/routes/register/+page.svelte -->
174
+ <script>
175
+ import { specable, register } from "svelte-specma";
176
+ import { emailSpec, passwordSpec, usernameSpec } from "$lib/shared/specs/userSpecs";
177
+
178
+ const email = specable("", { id: "email", required: true, spec: emailSpec });
179
+ const password = specable("", { id: "password", required: true, spec: passwordSpec });
180
+ const username = specable("", { id: "username", required: true, spec: usernameSpec });
181
+
182
+ async function handleSubmit() {
183
+ // Activate all fields
184
+ email.activate();
185
+ password.activate();
186
+ username.activate();
187
+
188
+ if ($email.valid && $password.valid && $username.valid) {
189
+ // Submit to server
190
+ const response = await fetch("/api/register", {
191
+ method: "POST",
192
+ headers: { "Content-Type": "application/json" },
193
+ body: JSON.stringify({
194
+ email: $email.value,
195
+ password: $password.value,
196
+ username: $username.value
197
+ })
198
+ });
199
+
200
+ if (response.ok) {
201
+ console.log("Registration successful!");
202
+ }
203
+ }
204
+ }
205
+ </script>
111
206
 
112
- - `active`: Boolean. Is the store currently active? Default to `false`.
207
+ <form on:submit|preventDefault={handleSubmit}>
208
+ <input use:register={username} placeholder="Username" />
209
+ {#if $username.active && $username.error}<p class="error">{$username.error}</p>{/if}
113
210
 
114
- - `changed`: Boolean. Is the value different (value based deep-equality) from the initial value?
211
+ <input use:register={email} type="email" placeholder="Email" />
212
+ {#if $email.active && $email.error}<p class="error">{$email.error}</p>{/if}
115
213
 
116
- - `valid`: Boolean. Is the value valid when checked against spec? Always `true` when store is not active. `false` if the store is validating asynchronously until a firm result is available.
214
+ <input use:register={password} type="password" placeholder="Password" />
215
+ {#if $password.active && $password.error}<p class="error">{$password.error}</p>{/if}
117
216
 
118
- - `validating`: Boolean. Is the store currently validating asynchronously?
217
+ <button type="submit">Register</button>
218
+ </form>
219
+ ```
119
220
 
120
- - `submitting`: Boolean. Is the store currently submitting asynchronously?
221
+ ### Server-Side Usage (Node.js/SvelteKit)
121
222
 
122
- - `error`: Any. Description of the error. Usually a string, but any value different than `true` returned from a predicate function validation.
223
+ ```js
224
+ // filepath: src/routes/api/register/+server.js
225
+ import { json } from "@sveltejs/kit";
226
+ import { conform } from "specma";
227
+ import {
228
+ emailSpec,
229
+ passwordSpec,
230
+ usernameSpec,
231
+ } from "$lib/shared/specs/userSpecs";
232
+
233
+ const registrationSpec = {
234
+ email: emailSpec,
235
+ password: passwordSpec,
236
+ username: usernameSpec,
237
+ };
123
238
 
124
- - `promise`: A promise of the validation result that resolves when asynchronous validation is completed. Property is always set even if result is synchronously available to allow waiting for resolution in any case.
239
+ export async function POST({ request }) {
240
+ const data = await request.json();
125
241
 
126
- - `id`: Any. A pass-through of the store's creation `id`.
242
+ // Validate using the same specs as the client
243
+ const result = conform(data, registrationSpec);
127
244
 
128
- # `collSpecable`
245
+ if (!result.valid) {
246
+ return json(
247
+ {
248
+ success: false,
249
+ errors: result.problems,
250
+ },
251
+ { status: 400 }
252
+ );
253
+ }
129
254
 
130
- A Svelte store used to validate a collection value (array, object, Map) against a Specma spec, including its children.
255
+ // Proceed with registration (save to database, etc.)
256
+ // ...
131
257
 
132
- The initial value is only used to generate all initial children specable stores, but the store's value is then derived from the values of those children stores. The `set` method actually sets the values of the underlying children stores.
258
+ return json({ success: true });
259
+ }
260
+ ```
133
261
 
134
- A `collSpecable` offers methods to modify its children specable stores : `add`, `remove`, `update`.
262
+ ### Complex Nested Specs
135
263
 
136
- ### Spec definition
264
+ For more complex scenarios with nested objects and arrays:
137
265
 
138
266
  ```js
139
- import { and, spread } from "specma";
140
-
141
- /* The spec could be shared between client and server */
142
- export const baseSpec = {
143
- name: (v = "") => v.length > 5 || "must be longer than 5 characters",
144
- list: spread({
145
- x: and(
146
- (v) => typeof v === "number" || "must be a number",
147
- (v) => v < 100 || "must be less than 100"
148
- ),
149
- y: (v) => ["foo", "bar"].includes(v) || "is not an acceptable choice",
150
- }),
267
+ // filepath: shared/specs/orderSpecs.js
268
+ import { spread } from "specma";
269
+
270
+ export const orderItemSpec = {
271
+ name: (v) => (v && v.length > 0) || "Product name is required",
272
+ quantity: (v) =>
273
+ (typeof v === "number" && v > 0) || "Quantity must be positive",
274
+ price: (v) => (typeof v === "number" && v >= 0) || "Invalid price",
275
+ };
276
+
277
+ export const shippingAddressSpec = {
278
+ street: (v) => (v && v.length > 0) || "Street is required",
279
+ city: (v) => (v && v.length > 0) || "City is required",
280
+ zipCode: (v) => /^\d{5}$/.test(v) || "Invalid ZIP code",
281
+ country: (v) => (v && v.length > 0) || "Country is required",
151
282
  };
283
+
284
+ export const orderSpec = {
285
+ customerEmail: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || "Invalid email",
286
+ items: spread(orderItemSpec),
287
+ shippingAddress: shippingAddressSpec,
288
+ };
289
+ ```
290
+
291
+ **Client usage:**
292
+
293
+ ```svelte
294
+ <script>
295
+ import { specable } from "svelte-specma";
296
+ import { orderSpec } from "$lib/shared/specs/orderSpecs";
297
+
298
+ const order = specable(
299
+ {
300
+ customerEmail: "",
301
+ items: [],
302
+ shippingAddress: { street: "", city: "", zipCode: "", country: "" }
303
+ },
304
+ {
305
+ spec: orderSpec,
306
+ getId: { items: (item) => item.id },
307
+ required: {
308
+ customerEmail: true,
309
+ items: spread({ name: true, quantity: true, price: true }),
310
+ shippingAddress: { street: true, city: true, zipCode: true, country: true }
311
+ }
312
+ }
313
+ );
314
+
315
+ const priceConverter = {
316
+ toInput: (v) => v == null ? "" : String(v),
317
+ toValue: (s) => s === "" ? null : Number(s)
318
+ };
319
+
320
+ async function handleSubmit() {
321
+ await order.submit();
322
+
323
+ if ($order.valid) {
324
+ console.log("Order submitted:", $order.value);
325
+ // Perform API call here
326
+ } else {
327
+ console.log("Validation errors:", $order.errors);
328
+ }
329
+ }
330
+ </script>
331
+
332
+ <form on:submit|preventDefault={handleSubmit}>
333
+ <div>
334
+ <label for="customerEmail">Email:</label>
335
+ <input id="customerEmail" type="email" use:register={order.getChild(["customerEmail"])} />
336
+ {#if $order.errors.customerEmail}
337
+ <p class="error">{$order.errors.customerEmail}</p>
338
+ {/if}
339
+ </div>
340
+
341
+ <div>
342
+ <label>Items:</label>
343
+ {#each $order.children as itemStore, index}
344
+ {@const item = $order.value[index]}
345
+ <div>
346
+ <input
347
+ placeholder="Product name"
348
+ bind:value={item.name}
349
+ on:blur={() => itemStore.activate()}
350
+ />
351
+
352
+ <input
353
+ type="number"
354
+ placeholder="Qty"
355
+ bind:value={item.quantity}
356
+ on:blur={() => itemStore.activate()}
357
+ />
358
+
359
+ <input
360
+ type="number"
361
+ step="0.01"
362
+ placeholder="Price"
363
+ bind:value={item.price}
364
+ on:blur={() => itemStore.activate()}
365
+ />
366
+
367
+ <button type="button" on:click={() => order.remove([index])}>Remove</button>
368
+
369
+ {#if $order.errors[index]}
370
+ <p class="error">{Object.values($order.errors[index]).join(", ")}</p>
371
+ {/if}
372
+ </div>
373
+ {/each}
374
+
375
+ <button type="button" on:click={() => order.add([{ id: Date.now(), name: "", quantity: 1, price: 0 }])}>
376
+ Add Item
377
+ </button>
378
+ </div>
379
+
380
+ <div>
381
+ <label>Shipping Address:</label>
382
+ <input
383
+ placeholder="Street"
384
+ use:register={order.getChild(["shippingAddress", "street"])}
385
+ />
386
+ {#if $order.errors["shippingAddress.street"]}
387
+ <p class="error">{$order.errors["shippingAddress.street"]}</p>
388
+ {/if}
389
+
390
+ <input
391
+ placeholder="City"
392
+ use:register={order.getChild(["shippingAddress", "city"])}
393
+ />
394
+ {#if $order.errors["shippingAddress.city"]}
395
+ <p class="error">{$order.errors["shippingAddress.city"]}</p>
396
+ {/if}
397
+
398
+ <input
399
+ placeholder="ZIP Code"
400
+ use:register={order.getChild(["shippingAddress", "zipCode"])}
401
+ />
402
+ {#if $order.errors["shippingAddress.zipCode"]}
403
+ <p class="error">{$order.errors["shippingAddress.zipCode"]}</p>
404
+ {/if}
405
+
406
+ <input
407
+ placeholder="Country"
408
+ use:register={order.getChild(["shippingAddress", "country"])}
409
+ />
410
+ {#if $order.errors["shippingAddress.country"]}
411
+ <p class="error">{$order.errors["shippingAddress.country"]}</p>
412
+ {/if}
413
+ </div>
414
+
415
+ <button type="submit" disabled={$order.submitting}>
416
+ {$order.submitting ? "Submitting..." : "Submit Order"}
417
+ </button>
418
+ </form>
152
419
  ```
153
420
 
154
- ### Usage with Svelte-Specma
421
+ **Server usage:**
155
422
 
156
423
  ```js
157
- import { and, spread } from "specma";
158
- import { baseSpec } from "/some/shared/file";
424
+ // filepath: src/routes/api/orders/+server.js
425
+ import { conform } from "specma";
426
+ import { orderSpec } from "$lib/shared/specs/orderSpecs";
159
427
 
160
- /* Client could enhance the base spec to add further validation
161
- * (async in this case) */
162
- const enhancedSpec = and(baseSpec, {
163
- name: async (v = "") => await checkNameIsUnique(v),
164
- });
428
+ export async function POST({ request }) {
429
+ const data = await request.json();
430
+ const result = conform(data, orderSpec);
165
431
 
166
- /* Will create a `collSpecable` store, since initial value is a collection */
167
- const store = specable(
168
- { name: "Foo", list: [{ id: "1234", x: 20, y: "abc", z: null }] },
169
- {
170
- spec: enhancedSpec,
171
- required: { name: 1, list: spread({ x: 1 }) },
172
- fields: { name: 1, list: spread({ x: 1, y: 1, z: 1 }) },
173
- getId: { list: ({ id }) => id },
174
- id: "myColl",
432
+ if (!result.valid) {
433
+ return json({ success: false, errors: result.problems }, { status: 400 });
175
434
  }
176
- );
177
435
 
178
- /* Store static properties and methods */
179
- const {
180
- id,
181
- isRequired,
182
- spec,
183
- activate,
184
- add,
185
- getChild,
186
- remove,
187
- reset,
188
- set,
189
- update,
190
- children,
191
- stores,
192
- subscribe,
193
- } = store;
194
-
195
- /* Store subscription internal state values */
196
- $: ({
197
- value,
198
- active,
199
- changed,
200
- valid,
201
- validating,
202
- error,
203
- errors,
204
- collErrors,
205
- promise,
206
- id,
207
- } = $store);
436
+ // Process order...
437
+ return json({ success: true, orderId: "12345" });
438
+ }
208
439
  ```
209
440
 
210
- ## Inputs
441
+ ### Benefits of Shared Specs
211
442
 
212
- - `initialValue`: Collection. The initial value that will generate all initial specable stores.
443
+ 1. **Single Source of Truth**: Define validation rules once, use everywhere
444
+ 2. **Consistency**: Client and server always validate the same way
445
+ 3. **Maintainability**: Changes to validation rules only need to be made in one place
446
+ 4. **Type Safety**: When using TypeScript, spec definitions can be typed once
447
+ 5. **Testing**: Write tests for specs once, applicable to both environments
448
+ 6. **Composability**: Build complex specs from smaller, reusable spec functions
213
449
 
214
- - `spec`: Collection. The Specma spec.
215
- - `required`: Collection. A deeply nested collection description of the required fields. Requiredness is defined at the root store to keep specs more reusable.
216
- - `fields`: Collection. A deeply nested collection description of which fields to expect, that will ensure that those fields are defined as children specable stores. Undeclared field values will be treated as constant and won't be displayed as children stores.
217
- - `getId`: Collection. A deeply nested collection description of how a subcollection should define the `id` of its children. Can be defined the same way as a spec (with `and`, `spread`, etc.), where the predicate function of a level has the shape `(value, key) => id`. If no function is provided, array items' store will be assigned a unique random id, while objects will use their key as id.
218
- - `changePred`: Collection. A deeply nested collection description of change predicates (see `predCheckable` inputs). Can be defined the same way as `getId`.
219
- - `id`: Used to uniquely identify the store. Mainly useful for values part of a list.
450
+ ## API summary
220
451
 
221
- ## Outputs
452
+ - `configure(specma)`: set the Specma implementation (required).
453
+ - `specable(initialValue, options)`: factory — returns `predSpecable` or `collSpecable`.
454
+ - `predSpecable`: single-value store exposing: `{ subscribe, set, reset, activate, submit, id, isRequired, spec }`.
455
+ - status fields in subscribe payload: `value`, `active`, `changed`, `valid`, `validating`, `submitting`, `error`, `promise`, `id`.
456
+ - `collSpecable`: collection-aware store exposing collection helpers:
457
+ - `add`, `remove`, `getChild`, `getChildren`, `update`, `set` (partial/complete), `reset`, `activate`, `submit`.
458
+ - `children`: readable store of child specable stores.
459
+ - subscribe payload contains aggregated status plus `errors` and flattened `errors` list.
460
+ - `register`: Svelte action: `use:register` on input elements (or pass `[store, {toInput,toValue}]`).
222
461
 
223
- - `id`: Any. A pass-through of the store's creation `id`.
462
+ ## Real-world patterns
224
463
 
225
- - `isRequired`: Boolean. Is the store value required? Based on the `required` creation argument.
464
+ - **Lazy validation**: create stores inactive by default and call `activate()` on blur or submit.
465
+ - **Centralized form submit**: call `submit()` on a `collSpecable` which will activate children and run configured `onSubmit` handlers.
466
+ - **Reuse specs between server and client**: define Specma specs once and share them in server validation and Svelte forms.
467
+ - **Composable validation**: build complex specs from smaller, reusable spec functions.
226
468
 
227
- - `spec`: Any. A pass-through of the store's creation spec.
469
+ ## Tips & best practices
228
470
 
229
- - `activate`: Async function. `(Boolean = true) => Promise`. Method to de/activate the store validation, including all its children stores. If set to true, will immediately trigger validation and return a promise that resolves to the `valid` result property.
471
+ - Call `configure(specma)` only once (e.g. in your app entry).
472
+ - Use `required` and `fields` options to avoid creating child stores for unneeded fields.
473
+ - For lists, supply `getId` so children retain identity across updates.
474
+ - Treat `predSpecable.submit` as the place to perform side effects (server calls); it can return a Promise.
475
+ - Use `register` for simple inputs — it reduces boilerplate for common cases.
476
+ - **Extract specs into shared modules** for reuse across client and server.
477
+ - **Use Specma helpers** like `spread()` for working with collections.
230
478
 
231
- - `add`: Function. `(coll) => store`. Method to add new children specable stores. Argument should be declared as a collection of the same type as the store's value. Returns the store for chaining.
479
+ ## Comprehensive Examples
232
480
 
233
- - `getChild`: Function. `(path = []) => store`. Method to retrieve a deeply nested child store from a path of keys (not ids) such as `["students", 0, "name"]`. Returns `null` if no store found.
481
+ ### Example 1: Simple Login Form with Email and Password Validation
234
482
 
235
- - `getChildren`: Function. `() => storesCollection`. Method to retrieve the current collection of children stores without having to subscribe to the `children` substore. Unlike the `stores` property, the return value of this method will be current each time it is called.
483
+ This example demonstrates a basic login form with synchronous validation for email and password fields.
236
484
 
237
- - `remove`: Function. `(idsToRemove) => store`. Method to remove children specable stores. Will remove stores by their id. Returns the store for chaining.
485
+ ```svelte
486
+ <!-- LoginForm.svelte -->
487
+ <script>
488
+ import { specable, register } from "svelte-specma";
489
+
490
+ const email = specable("", {
491
+ id: "email",
492
+ required: true,
493
+ spec: (v) => {
494
+ const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
495
+ return emailPattern.test(v) || "Invalid email address";
496
+ }
497
+ });
498
+
499
+ const password = specable("", {
500
+ id: "password",
501
+ required: true,
502
+ spec: (v) => (v.length >= 8 ? true : "Password must be at least 8 characters")
503
+ });
504
+
505
+ function handleSubmit() {
506
+ email.activate();
507
+ password.activate();
508
+
509
+ if ($email.valid && $password.valid) {
510
+ console.log("Form submitted:", { email: $email.value, password: $password.value });
511
+ }
512
+ }
513
+ </script>
514
+
515
+ <form on:submit|preventDefault={handleSubmit}>
516
+ <div>
517
+ <label for="email">Email:</label>
518
+ <input id="email" type="email" use:register={email} />
519
+ {#if $email.active && $email.error}
520
+ <p class="error">{$email.error}</p>
521
+ {/if}
522
+ </div>
523
+
524
+ <div>
525
+ <label for="password">Password:</label>
526
+ <input id="password" type="password" use:register={password} />
527
+ {#if $password.active && $password.error}
528
+ <p class="error">{$password.error}</p>
529
+ {/if}
530
+ </div>
531
+
532
+ <button type="submit">Login</button>
533
+ </form>
534
+
535
+ <style>
536
+ .error { color: red; font-size: 0.875rem; }
537
+ </style>
538
+ ```
238
539
 
239
- - `set`: Function. `(coll, partial = false, shouldActivate = false) => store`. Method to recursively set the values of the collection's underlying stores. If `partial = true`, sets only the values that are not `undefined`. If `shouldActivate = true`, activates the store after setting the value. Returns the store for chaining.
540
+ ### Example 2: User Profile Form with Nested Validation
240
541
 
241
- - `update`: Function. `(fn) => store`. Method to modify the children specable stores collection by applying a function `(storesCollection) => storesCollection` to it that returns a modified children stores collection. It operates on the collection of children stores, NOT the underlying value. To update the value itself, use `set` with a modified `$store.value`. Useful for instance to reorder the children based on their id. Returns the store for chaining.
542
+ This example shows a more complex form with nested object validation including cross-field validation.
242
543
 
243
- - `children`: Svelte readable store. A store that holds a reactive collection of the children specable stores that compose the collection. Subscribe to it to watch changes to a list of children, where some could be added, removed, reordered, etc.
544
+ ```svelte
545
+ <!-- UserProfileForm.svelte -->
546
+ <script>
547
+ import { specable, register } from "svelte-specma";
548
+
549
+ const profile = specable(
550
+ {
551
+ username: "",
552
+ age: null,
553
+ contact: {
554
+ email: "",
555
+ phone: ""
556
+ }
557
+ },
558
+ {
559
+ spec: {
560
+ username: (v) => (v && v.length >= 3 ? true : "Username must be at least 3 characters"),
561
+ age: (v) => (typeof v === "number" && v >= 18 && v <= 120 ? true : "Age must be between 18 and 120"),
562
+ contact: {
563
+ email: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || "Invalid email",
564
+ phone: (v) => /^\d{10}$/.test(v) || "Phone must be 10 digits"
565
+ }
566
+ },
567
+ required: {
568
+ username: true,
569
+ age: true,
570
+ contact: { email: true }
571
+ }
572
+ }
573
+ );
574
+
575
+ const ageConverter = {
576
+ toInput: (v) => v == null ? "" : String(v),
577
+ toValue: (s) => s === "" ? null : Number(s)
578
+ };
579
+
580
+ async function handleSubmit() {
581
+ await profile.submit();
582
+
583
+ if ($profile.valid) {
584
+ console.log("Profile submitted:", $profile.value);
585
+ // Perform API call here
586
+ } else {
587
+ console.log("Validation errors:", $profile.errors);
588
+ }
589
+ }
590
+ </script>
591
+
592
+ <form on:submit|preventDefault={handleSubmit}>
593
+ <div>
594
+ <label for="username">Username:</label>
595
+ <input id="username" use:register={profile.getChild(["username"])} />
596
+ {#if $profile.errors.username}
597
+ <p class="error">{$profile.errors.username}</p>
598
+ {/if}
599
+ </div>
600
+
601
+ <div>
602
+ <label for="age">Age:</label>
603
+ <input id="age" type="number" use:register={[profile.getChild(["age"]), ageConverter]} />
604
+ {#if $profile.errors.age}
605
+ <p class="error">{$profile.errors.age}</p>
606
+ {/if}
607
+ </div>
608
+
609
+ <div>
610
+ <label for="email">Email:</label>
611
+ <input id="email" type="email" use:register={profile.getChild(["contact", "email"])} />
612
+ {#if $profile.errors["contact.email"]}
613
+ <p class="error">{$profile.errors["contact.email"]}</p>
614
+ {/if}
615
+ </div>
616
+
617
+ <div>
618
+ <label for="phone">Phone (optional):</label>
619
+ <input id="phone" type="tel" use:register={profile.getChild(["contact", "phone"])} />
620
+ {#if $profile.errors["contact.phone"]}
621
+ <p class="error">{$profile.errors["contact.phone"]}</p>
622
+ {/if}
623
+ </div>
624
+
625
+ <button type="submit" disabled={$profile.submitting}>
626
+ {$profile.submitting ? "Submitting..." : "Save Profile"}
627
+ </button>
628
+ </form>
629
+ ```
244
630
 
245
- - `stores`: Collection. Same as `children`, but non-reactive. Useful to destructure children stores that won't change over time, such as the fixed fields in a flat form, without having to subscribe first.
631
+ ### Example 3: Dynamic List Management (Shopping Cart)
246
632
 
247
- - `submit`: Function. `(value) => {}`. Method to first activate then handle the current value. `submitting` state is set to `true` during submit.
633
+ This example demonstrates managing a dynamic list with add/remove operations and per-item validation.
248
634
 
249
- - `subscribe`: Function. Same as `predSpecable`, but with added state properties, listed below.
635
+ ```svelte
636
+ <!-- ShoppingCart.svelte -->
637
+ <script>
638
+ import { specable } from "svelte-specma";
639
+ import { spread } from "specma";
640
+
641
+ const itemSpec = {
642
+ name: (v) => (v && v.length > 0 ? true : "Product name is required"),
643
+ quantity: (v) => (typeof v === "number" && v > 0 ? true : "Quantity must be positive"),
644
+ price: (v) => (typeof v === "number" && v >= 0 ? true : "Invalid price")
645
+ };
646
+
647
+ const cart = specable(
648
+ [],
649
+ {
650
+ spec: spread(itemSpec),
651
+ getId: (item) => item.id,
652
+ required: spread({ name: true, quantity: true, price: true })
653
+ }
654
+ );
655
+
656
+ let nextId = 1;
657
+
658
+ function addItem() {
659
+ cart.add([{
660
+ id: String(nextId++),
661
+ name: "",
662
+ quantity: 1,
663
+ price: 0
664
+ }]);
665
+ }
250
666
 
251
- - `error`: Any. The collection's own predicate spec error result.
667
+ function removeItem(id) {
668
+ const index = $cart.value.findIndex(item => item.id === id);
669
+ if (index !== -1) {
670
+ cart.remove([index]);
671
+ }
672
+ }
252
673
 
253
- - `errors`: Array. Array of `[{ error, path, which, isColl }]` containing all error descriptions. Useful to display all errors in a centralized location on a form, for instance.
674
+ function calculateTotal() {
675
+ return $cart.value.reduce((sum, item) => sum + (item.quantity || 0) * (item.price || 0), 0);
676
+ }
254
677
 
255
- - `error`: Any. Description of the error.
256
- - `path`: Array. List of the complete path from root to error node.
257
- - `which`: String. Same as `path`, but joined with dots to form a string. Useful to lookup predefined captions in a dictionnary.
258
- - `isColl`: Boolean. Indicates if the error is on a collection value.
678
+ async function checkout() {
679
+ await cart.submit();
259
680
 
260
- - `collErrors`: Array. Same as `errors`, but containing only the errors with the `isColl` flag. Useful to display collection errors in a central location, while primitive field errors are displayed near their input in a form, for instance.
681
+ if ($cart.valid) {
682
+ console.log("Checkout:", $cart.value);
683
+ console.log("Total:", calculateTotal());
684
+ // Perform checkout API call
685
+ }
686
+ }
687
+ </script>
688
+
689
+ <div>
690
+ <h2>Shopping Cart</h2>
691
+
692
+ {#each $cart.children as itemStore, index}
693
+ {@const item = $cart.value[index]}
694
+ <div class="cart-item">
695
+ <input
696
+ placeholder="Product name"
697
+ bind:value={item.name}
698
+ on:blur={() => itemStore.activate()}
699
+ />
700
+
701
+ <input
702
+ type="number"
703
+ placeholder="Qty"
704
+ bind:value={item.quantity}
705
+ on:blur={() => itemStore.activate()}
706
+ />
707
+
708
+ <input
709
+ type="number"
710
+ step="0.01"
711
+ placeholder="Price"
712
+ bind:value={item.price}
713
+ on:blur={() => itemStore.activate()}
714
+ />
715
+
716
+ <button type="button" on:click={() => removeItem(item.id)}>Remove</button>
717
+
718
+ {#if $cart.errors[index]}
719
+ <p class="error">{Object.values($cart.errors[index]).join(", ")}</p>
720
+ {/if}
721
+ </div>
722
+ {/each}
723
+
724
+ <button type="button" on:click={addItem}>Add Item</button>
725
+
726
+ {#if $cart.value.length > 0}
727
+ <div class="total">
728
+ <strong>Total: ${calculateTotal().toFixed(2)}</strong>
729
+ </div>
730
+ <button on:click={checkout} disabled={$cart.submitting}>
731
+ {$cart.submitting ? "Processing..." : "Checkout"}
732
+ </button>
733
+ {/if}
734
+ </div>
735
+
736
+ <style>
737
+ .cart-item { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; }
738
+ .error { color: red; font-size: 0.875rem; }
739
+ .total { margin-top: 1rem; font-size: 1.25rem; }
740
+ </style>
741
+ ```
261
742
 
262
- # `register`
743
+ ### Example 4: Async Validation (Username Availability Check)
263
744
 
264
- The `register` function facilitates usage of the [`predSpecable`](#predSpecable) store in conjunction to a form input. It is designed to be used with the `use:register` directive:
745
+ This example shows how to use asynchronous validation to check if a username is available.
265
746
 
266
747
  ```svelte
267
- <input use:register="{predSpecableStore}" />
748
+ <!-- UsernameCheckForm.svelte -->
749
+ <script>
750
+ import { specable, register } from "svelte-specma";
751
+
752
+ const username = specable("", {
753
+ id: "username",
754
+ required: true,
755
+ spec: (v) => (v.length >= 3 ? true : "Username must be at least 3 characters")
756
+ });
757
+
758
+ let checking = false;
759
+ let available = false;
760
+
761
+ async function checkAvailability() {
762
+ checking = true;
763
+ available = false;
764
+
765
+ // Simulate an API call to check username availability
766
+ const isAvailable = await new Promise((resolve) => {
767
+ setTimeout(() => {
768
+ resolve(Math.random() > 0.5);
769
+ }, 1000);
770
+ });
771
+
772
+ checking = false;
773
+ available = isAvailable;
774
+
775
+ if (!isAvailable) {
776
+ username.setError("Username is already taken");
777
+ } else {
778
+ username.clearError();
779
+ }
780
+ }
781
+
782
+ function handleSubmit() {
783
+ username.activate();
784
+
785
+ if ($username.valid && available) {
786
+ console.log("Form submitted:", { username: $username.value });
787
+ }
788
+ }
789
+ </script>
790
+
791
+ <form on:submit|preventDefault={handleSubmit}>
792
+ <div>
793
+ <label for="username">Username:</label>
794
+ <input id="username" use:register={username} />
795
+ {#if $username.active && $username.error}
796
+ <p class="error">{$username.error}</p>
797
+ {/if}
798
+ <button type="button" on:click={checkAvailability} disabled={checking}>
799
+ {#if checking}Checking...{#else}Check Availability{/if}
800
+ </button>
801
+ {#if available}
802
+ <p class="available">Username is available!</p>
803
+ {:else if $username.active && !$username.valid}
804
+ <p class="error">Username must be at least 3 characters</p>
805
+ {/if}
806
+ </div>
807
+
808
+ <button type="submit">Submit</button>
809
+ </form>
810
+
811
+ <style>
812
+ .error { color: red; font-size: 0.875rem; }
813
+ .available { color: green; font-size: 0.875rem; }
814
+ </style>
268
815
  ```
269
816
 
270
- Doing so will:
817
+ ### Example 5: Shared Validation (Full Stack)
271
818
 
272
- - update the store's value on input;
273
- - update the input on store value change;
274
- - activate the store on input blur;
275
- - remove all listeners and subscriptions when input is unmounted.
819
+ This example demonstrates the full power of reusable specs across client and server.
276
820
 
277
- If the expected value's type is not the same as the HTML input's one (if validation expects an number or a JS Date instance, for example), `use:register` can be used with a list of arguments where the second one is an object with keys `{ toInput, toValue }`:
821
+ **Shared specs:**
278
822
 
279
- - `toInput`: Function. `(value) => htmlInputValue`;
280
- - `toValue`: Function. `(htmlInputValue) => value`;
823
+ ```js
824
+ // filepath: lib/shared/specs/productSpecs.js
825
+ import { spread } from "specma";
826
+
827
+ export const productNameSpec = (v) =>
828
+ v && v.length >= 3 && v.length <= 100
829
+ ? true
830
+ : "Product name must be 3-100 characters";
831
+
832
+ export const productPriceSpec = (v) =>
833
+ typeof v === "number" && v >= 0 && v <= 1000000
834
+ ? true
835
+ : "Price must be between 0 and 1,000,000";
836
+
837
+ export const productDescriptionSpec = (v) =>
838
+ v && v.length >= 10 && v.length <= 500
839
+ ? true
840
+ : "Description must be 10-500 characters";
841
+
842
+ export const productCategorySpec = (v) =>
843
+ ["electronics", "clothing", "food", "books", "other"].includes(v)
844
+ ? true
845
+ : "Invalid category";
846
+
847
+ export const productSpec = {
848
+ name: productNameSpec,
849
+ price: productPriceSpec,
850
+ description: productDescriptionSpec,
851
+ category: productCategorySpec,
852
+ };
281
853
 
282
- For example, if the expected value is a number, it could be used like so:
854
+ export const productRequired = {
855
+ name: true,
856
+ price: true,
857
+ description: true,
858
+ category: true,
859
+ };
860
+ ```
861
+
862
+ **Client-side form:**
283
863
 
284
864
  ```svelte
285
- <input
286
- use:register="{[predSpecableStore, { toValue: (x) => x && +x }]}"
287
- />
865
+ <!-- filepath: src/routes/products/new/+page.svelte -->
866
+ <script>
867
+ import { specable, register } from "svelte-specma";
868
+ import { productSpec, productRequired } from "$lib/shared/specs/productSpecs";
869
+
870
+ const product = specable(
871
+ { name: "", price: null, description: "", category: "" },
872
+ { spec: productSpec, required: productRequired }
873
+ );
874
+
875
+ const priceConverter = {
876
+ toInput: (v) => v == null ? "" : String(v),
877
+ toValue: (s) => s === "" ? null : Number(s)
878
+ };
879
+
880
+ async function handleSubmit() {
881
+ await product.submit();
882
+
883
+ if ($product.valid) {
884
+ const response = await fetch("/api/products", {
885
+ method: "POST",
886
+ headers: { "Content-Type": "application/json" },
887
+ body: JSON.stringify($product.value)
888
+ });
889
+
890
+ if (response.ok) {
891
+ alert("Product created successfully!");
892
+ } else {
893
+ const error = await response.json();
894
+ alert(`Server error: ${JSON.stringify(error.errors)}`);
895
+ }
896
+ }
897
+ }
898
+ </script>
899
+
900
+ <form on:submit|preventDefault={handleSubmit}>
901
+ <input use:register={product.getChild(["name"])} placeholder="Product name" />
902
+ {#if $product.errors.name}<p class="error">{$product.errors.name}</p>{/if}
903
+
904
+ <input use:register={[product.getChild(["price"]), priceConverter]}
905
+ type="number" step="0.01" placeholder="Price" />
906
+ {#if $product.errors.price}<p class="error">{$product.errors.price}</p>{/if}
907
+
908
+ <textarea use:register={product.getChild(["description"])}
909
+ placeholder="Description"></textarea>
910
+ {#if $product.errors.description}<p class="error">{$product.errors.description}</p>{/if}
911
+
912
+ <select use:register={product.getChild(["category"])}>
913
+ <option value="">Select category</option>
914
+ <option value="electronics">Electronics</option>
915
+ <option value="clothing">Clothing</option>
916
+ <option value="food">Food</option>
917
+ <option value="books">Books</option>
918
+ <option value="other">Other</option>
919
+ </select>
920
+ {#if $product.errors.category}<p class="error">{$product.errors.category}</p>{/if}
921
+
922
+ <button type="submit" disabled={$product.submitting}>
923
+ {$product.submitting ? "Creating..." : "Create Product"}
924
+ </button>
925
+ </form>
288
926
  ```
289
927
 
290
- In any other case, the store should be used explicitely:
928
+ **Server-side validation:**
291
929
 
292
- ```svelte
293
- <input
294
- value={$age.value}
295
- on:input={(e) => age.set(+e.target.value)}
296
- on:blur={() => age.activate()}
297
- />
930
+ ```js
931
+ // filepath: src/routes/api/products/+server.js
932
+ import { json } from "@sveltejs/kit";
933
+ import { conform } from "specma";
934
+ import { productSpec } from "$lib/shared/specs/productSpecs";
935
+
936
+ export async function POST({ request }) {
937
+ const data = await request.json();
938
+
939
+ // Validate using the exact same specs as the client
940
+ const result = conform(data, productSpec);
941
+
942
+ if (!result.valid) {
943
+ return json(
944
+ {
945
+ success: false,
946
+ errors: result.problems,
947
+ },
948
+ { status: 400 }
949
+ );
950
+ }
951
+
952
+ // Save to database
953
+ // const productId = await db.products.create(result.value);
954
+
955
+ return json({
956
+ success: true,
957
+ productId: "mock-id-12345",
958
+ });
959
+ }
298
960
  ```
961
+
962
+ This example demonstrates:
963
+
964
+ - **Spec extraction** into a shared module
965
+ - **Client-side** reactive validation with Svelte stores
966
+ - **Server-side** validation using the same specs
967
+ - **Consistent validation** across the full stack
968
+ - **Type safety** and maintainability improvements