@sprucelabs/schema 34.0.7 → 34.1.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.
- package/README.MD +933 -20
- package/build/StaticSchemaEntityImpl.js +5 -1
- package/build/esm/StaticSchemaEntityImpl.js +5 -1
- package/build/esm/fields/AbstractField.d.ts +1 -0
- package/build/esm/fields/AbstractField.js +3 -0
- package/build/esm/fields/field.static.types.d.ts +2 -0
- package/build/fields/AbstractField.d.ts +1 -0
- package/build/fields/AbstractField.js +3 -0
- package/build/fields/field.static.types.d.ts +2 -0
- package/package.json +1 -1
package/README.MD
CHANGED
|
@@ -1,23 +1,936 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
1
|
+
# @sprucelabs/schema
|
|
2
|
+
|
|
3
|
+
> Static and dynamic binding plus runtime validation and transformation to ensure your app is sound. 🤓
|
|
4
|
+
|
|
5
|
+
Define the shape of your data **once** and get everything else for free: bulletproof TypeScript types, runtime validation with human-friendly error messages, value normalization (coercing `'10'` → `10`, formatting phone numbers, truncating strings), default values, private fields, versioning, nested relationships, and even code generation for other languages.
|
|
6
|
+
|
|
7
|
+
If you've ever kept an interface, a validator, and a sanitizer in sync by hand — this library exists so you never have to again.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
yarn add @sprucelabs/schema
|
|
11
|
+
# or
|
|
12
|
+
npm install @sprucelabs/schema
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Full platform docs: [developer.spruce.ai](https://developer.spruce.ai/#/schemas/index)
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Table of contents
|
|
20
|
+
|
|
21
|
+
- [Quick start](#quick-start)
|
|
22
|
+
- [Core concepts](#core-concepts)
|
|
23
|
+
- [Field types](#field-types)
|
|
24
|
+
- [Building schemas](#building-schemas)
|
|
25
|
+
- [Validating values](#validating-values)
|
|
26
|
+
- [Normalizing values](#normalizing-values)
|
|
27
|
+
- [Default values](#default-values)
|
|
28
|
+
- [Schema entities](#schema-entities)
|
|
29
|
+
- [Dynamic schemas](#dynamic-schemas)
|
|
30
|
+
- [Nested schemas & relationships](#nested-schemas--relationships)
|
|
31
|
+
- [Dot notation](#dot-notation)
|
|
32
|
+
- [The schema registry & versioning](#the-schema-registry--versioning)
|
|
33
|
+
- [TypeScript type helpers](#typescript-type-helpers)
|
|
34
|
+
- [Errors](#errors)
|
|
35
|
+
- [Utilities](#utilities)
|
|
36
|
+
- [Testing helpers](#testing-helpers)
|
|
37
|
+
- [Custom field types](#custom-field-types)
|
|
38
|
+
- [Code generation](#code-generation)
|
|
39
|
+
- [Gotchas & good-to-knows](#gotchas--good-to-knows)
|
|
40
|
+
- [Contributing / local development](#contributing--local-development)
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import {
|
|
48
|
+
buildSchema,
|
|
49
|
+
validateSchemaValues,
|
|
50
|
+
normalizeSchemaValues,
|
|
51
|
+
SchemaValues,
|
|
52
|
+
} from '@sprucelabs/schema'
|
|
53
|
+
|
|
54
|
+
// 1. Define a schema (a plain object — buildSchema preserves its literal type)
|
|
55
|
+
const personSchema = buildSchema({
|
|
56
|
+
id: 'person',
|
|
57
|
+
name: 'Person',
|
|
58
|
+
fields: {
|
|
59
|
+
firstName: { type: 'text', label: 'First name', isRequired: true },
|
|
60
|
+
lastName: { type: 'text', label: 'Last name' },
|
|
61
|
+
age: { type: 'number' },
|
|
62
|
+
phone: { type: 'phone' },
|
|
63
|
+
favoriteColors: { type: 'text', isArray: true, minArrayLength: 0 },
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// 2. Get the TypeScript type for free
|
|
68
|
+
type Person = SchemaValues<typeof personSchema>
|
|
69
|
+
// { firstName: string; lastName?: string | null; age?: number | null;
|
|
70
|
+
// phone?: string | null; favoriteColors?: string[] | null }
|
|
71
|
+
|
|
72
|
+
// 3. Validate untrusted values (throws VALIDATION_FAILED with friendly messages)
|
|
73
|
+
const values = { firstName: 'Tay', age: '32' as any }
|
|
74
|
+
validateSchemaValues(personSchema, values)
|
|
75
|
+
// after this line, TypeScript narrows `values` to a full Person ✨
|
|
76
|
+
|
|
77
|
+
// 4. Normalize/coerce loose input into typed values
|
|
78
|
+
const person = normalizeSchemaValues(personSchema, {
|
|
79
|
+
firstName: 12345, // -> '12345'
|
|
80
|
+
age: '10', // -> 10
|
|
81
|
+
phone: '5555555555', // -> '+1 555-555-5555'
|
|
82
|
+
})
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
A failed validation renders like this:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
'person' has 2 errors!
|
|
89
|
+
|
|
90
|
+
1. 'First name' is required!
|
|
91
|
+
2. '"whoops" is not a number!'
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Core concepts
|
|
97
|
+
|
|
98
|
+
**Schema** — a plain object literal describing your data. Only `id` is required, plus either `fields` (static) or `dynamicFieldSignature` (dynamic):
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
interface Schema {
|
|
102
|
+
id: string
|
|
103
|
+
name?: string // human-readable name
|
|
104
|
+
version?: string // e.g. 'v2020_07_22'
|
|
105
|
+
namespace?: string // e.g. 'MyOrg'
|
|
106
|
+
description?: string
|
|
107
|
+
fields?: Record<string, FieldDefinition> // static schemas
|
|
108
|
+
dynamicFieldSignature?: FieldDefinition & { keyName: string } // dynamic schemas
|
|
109
|
+
// ...plus codegen hints: importsWhenLocal, importsWhenRemote,
|
|
110
|
+
// moduleToImportFromWhenRemote, typeSuffix
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**Field definition** — describes one field. Every field type shares these options:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
{
|
|
118
|
+
type: 'text' | 'number' | 'boolean' | ... // see Field types below
|
|
119
|
+
label?: string // human label — used in error messages
|
|
120
|
+
hint?: string // help text — rendered as comments in generated types
|
|
121
|
+
isRequired?: boolean
|
|
122
|
+
isPrivate?: boolean // strippable via dropPrivateFields / shouldIncludePrivateFields: false
|
|
123
|
+
isArray?: boolean // value becomes T[]; defaultValue/value become arrays too
|
|
124
|
+
minArrayLength?: number // defaults to 1 for required arrays — set 0 to allow []
|
|
125
|
+
maxArrayLength?: number
|
|
126
|
+
defaultValue?: ... // surfaced by defaultSchemaValues() / getDefaultValues()
|
|
127
|
+
value?: ... // hardcoded initial value, applied on entity construction
|
|
128
|
+
options?: ... // per-field-type options (choices, valueType, schema, etc.)
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**Entity** — a live wrapper around a schema + values with `get`/`set`/`validate`/`getValues` and friends. You usually don't need entities directly — the `validateSchemaValues`/`normalizeSchemaValues` utilities create them under the hood — but they're great for form-like flows.
|
|
133
|
+
|
|
134
|
+
**Static vs dynamic** — static schemas have named fields known at compile time; dynamic schemas accept *any* key, with all values sharing one field definition (think `Record<string, number>`).
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Field types
|
|
139
|
+
|
|
140
|
+
| `type` | Value type | Options | Notes |
|
|
141
|
+
|---|---|---|---|
|
|
142
|
+
| `text` | `string` | `minLength?`, `maxLength?` | Coerces numbers to strings; `maxLength` truncates on normalize |
|
|
143
|
+
| `boolean` | `boolean` | — | `'true'`/`'false'` strings convert; everything else is `!!value` |
|
|
144
|
+
| `number` | `number` | `min?`, `max?` | Coerces numeric strings (`'42'` → `42`) |
|
|
145
|
+
| `select` | union of choice values | `choices: { value, label }[]` (required) | Generated type is a literal union like `'small' \| 'large'` |
|
|
146
|
+
| `phone` | `string` | — | Formats to `+1 555-555-5555`; validates via `isValidNumber` |
|
|
147
|
+
| `email` | `string` | — | Validates format via `email-validator` |
|
|
148
|
+
| `date` | `number` (epoch ms) | — | Normalizes to **start of day, UTC** |
|
|
149
|
+
| `dateTime` | `number` (epoch ms) | `dateTimeFormat?: 'epoch' \| 'iso_8601'` | Accepts `Date`, ISO strings, timestamps |
|
|
150
|
+
| `duration` | `{ hours, minutes, seconds, ms }` | `durationFormat?`, `minDuration?`, `maxDuration?` | `buildDuration()` accepts ms, strings, or partial objects |
|
|
151
|
+
| `address` | `{ street1, street2?, city, province, country, zip }` | — | |
|
|
152
|
+
| `id` | `string` | — | Unique identifier (UUID4 in Spruce); stringifies on normalize |
|
|
153
|
+
| `file` | `{ name, id?, type?, uri?, base64?, previewUrl? }` | `acceptableTypes: SupportedFileType[]` | Mime types, incl. wildcards like `'image/*'` and `'*'` |
|
|
154
|
+
| `image` | `{ name, sUri?, mUri?, lUri?, xlUri?, base64?, ... }` | `requiredSizes: ('s'\|'m'\|'l'\|'xl'\|'*')[]` | `'*'` expands to all sizes; `base64` values skip the size check (upload path) |
|
|
155
|
+
| `directory` | `{ path: string }` | `relativeTo?: string` | Normalizes to a path relative to `relativeTo` when set |
|
|
156
|
+
| `raw` | whatever you say it is | `valueType: string` (required) | Escape hatch — the string is dropped verbatim into generated types; **no runtime validation** |
|
|
157
|
+
| `schema` | nested values / entity / union | `schema?`, `schemaId?`, `schemas?`, `schemaIds?`, `schemasCallback?`, `typeSuffix?` | See [Nested schemas](#nested-schemas--relationships) |
|
|
158
|
+
|
|
159
|
+
Some illustrative definitions:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
const kitchenSinkSchema = buildSchema({
|
|
163
|
+
id: 'kitchenSink',
|
|
164
|
+
fields: {
|
|
165
|
+
name: { type: 'text', isRequired: true, options: { maxLength: 50 } },
|
|
166
|
+
status: {
|
|
167
|
+
type: 'select',
|
|
168
|
+
isRequired: true,
|
|
169
|
+
defaultValue: 'draft',
|
|
170
|
+
options: {
|
|
171
|
+
choices: [
|
|
172
|
+
{ value: 'draft', label: 'Draft' },
|
|
173
|
+
{ value: 'live', label: 'Live' },
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
tags: { type: 'text', isArray: true, minArrayLength: 0, maxArrayLength: 5 },
|
|
178
|
+
lengthOfBooking: {
|
|
179
|
+
type: 'duration',
|
|
180
|
+
defaultValue: { hours: 1, minutes: 0, seconds: 0, ms: 0 },
|
|
181
|
+
},
|
|
182
|
+
attachment: {
|
|
183
|
+
type: 'file',
|
|
184
|
+
options: { acceptableTypes: ['application/pdf', 'image/*'] },
|
|
185
|
+
},
|
|
186
|
+
avatar: { type: 'image', options: { requiredSizes: ['s', 'm'] } },
|
|
187
|
+
meta: { type: 'raw', options: { valueType: 'Record<string, any>' } },
|
|
188
|
+
ssn: { type: 'text', isPrivate: true },
|
|
189
|
+
},
|
|
190
|
+
})
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Array rules worth knowing:
|
|
194
|
+
|
|
195
|
+
- A **required array field defaults to `minArrayLength: 1`** — `[]` fails validation. Set `minArrayLength: 0` to allow empty arrays.
|
|
196
|
+
- `maxArrayLength` caps how many values are allowed.
|
|
197
|
+
- A non-array value on an `isArray` field fails with `'X' must be an array!`.
|
|
198
|
+
|
|
199
|
+
Duration helpers are exported directly:
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
import { buildDuration, reduceDurationToMs } from '@sprucelabs/schema'
|
|
203
|
+
|
|
204
|
+
buildDuration({ hours: 2.52 }) // { hours: 2, minutes: 31, seconds: 12, ms: 0 }
|
|
205
|
+
buildDuration(90_000) // { hours: 0, minutes: 1, seconds: 30, ms: 0 }
|
|
206
|
+
reduceDurationToMs({ hours: 0, minutes: 1, seconds: 30, ms: 0 }) // 90000
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## Building schemas
|
|
212
|
+
|
|
213
|
+
### `buildSchema(schema)`
|
|
214
|
+
|
|
215
|
+
An identity function with a very important generic: it **preserves the literal type** of what you pass, which is what makes `SchemaValues<typeof schema>`, field-name autocomplete, and select-choice unions all work. It also registers the schema in the [SchemaRegistry](#the-schema-registry--versioning).
|
|
216
|
+
|
|
217
|
+
### `buildErrorSchema(schema)`
|
|
218
|
+
|
|
219
|
+
Same literal-type trick, **without** registry tracking. Use it for error-option schemas so they don't pollute (or collide in) the registry.
|
|
220
|
+
|
|
221
|
+
### Composing schemas from other schemas
|
|
222
|
+
|
|
223
|
+
Four type-exact helpers operate on a schema's `fields` map:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
import {
|
|
227
|
+
buildSchema,
|
|
228
|
+
dropFields,
|
|
229
|
+
pickFields,
|
|
230
|
+
dropPrivateFields,
|
|
231
|
+
makeFieldsOptional,
|
|
232
|
+
} from '@sprucelabs/schema'
|
|
233
|
+
|
|
234
|
+
// CRUD-style variants of one canonical schema:
|
|
235
|
+
const createPersonSchema = buildSchema({
|
|
236
|
+
id: 'createPerson',
|
|
237
|
+
fields: dropFields(personSchema.fields, ['id']),
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
const updatePersonSchema = buildSchema({
|
|
241
|
+
id: 'updatePerson',
|
|
242
|
+
fields: makeFieldsOptional(dropFields(personSchema.fields, ['id'])),
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
const publicPersonSchema = buildSchema({
|
|
246
|
+
id: 'publicPerson',
|
|
247
|
+
fields: dropPrivateFields(personSchema.fields), // strips isPrivate: true
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
const personNameSchema = buildSchema({
|
|
251
|
+
id: 'personName',
|
|
252
|
+
fields: pickFields(personSchema.fields, ['firstName', 'lastName']),
|
|
253
|
+
})
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
All four work at both runtime **and** the type level — the resulting `SchemaValues` types reflect the dropped/optional fields exactly.
|
|
257
|
+
|
|
258
|
+
### `getFields(schema)`
|
|
259
|
+
|
|
260
|
+
Returns the field names typed as the schema's field-name union. Throws `INVALID_PARAMETERS` if the schema has no fields.
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
const names = getFields(personSchema) // ('firstName' | 'lastName' | ...)[]
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Validating values
|
|
269
|
+
|
|
270
|
+
### `validateSchemaValues(schema, values, options?)`
|
|
271
|
+
|
|
272
|
+
The workhorse. Throws a `SchemaError` with code `VALIDATION_FAILED` if anything is wrong — and it's an **assertion function**, so on success TypeScript narrows your partial values to the full `SchemaValues<S>`:
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
const values: SchemaPartialValues<typeof personSchema> = { firstName: 'Tay' }
|
|
276
|
+
validateSchemaValues(personSchema, values)
|
|
277
|
+
values.firstName // string — no longer string | undefined
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Options:
|
|
281
|
+
|
|
282
|
+
- `fields?: SchemaFieldNames<S>[]` — validate only a subset:
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
validateSchemaValues(personSchema, values, { fields: ['firstName'] })
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Dot-notation keys in `values` (`{ 'source.organizationId': 'abc' }`) are expanded before validation.
|
|
289
|
+
|
|
290
|
+
What gets checked, per field:
|
|
291
|
+
|
|
292
|
+
1. Unknown keys → `UNEXPECTED_PARAMETER` (`` `nope` does not exist. ``)
|
|
293
|
+
2. Required + missing → `MISSING_PARAMETER` (`'First name' is required!`)
|
|
294
|
+
3. Array fields: non-array value, fewer than `minArrayLength`, or more than `maxArrayLength` → `INVALID_PARAMETER`
|
|
295
|
+
4. Each value (or each array element) runs through the field's own validator — bad emails, invalid phone numbers, out-of-choices selects, malformed dates, wrong nested-schema shapes, etc.
|
|
296
|
+
5. Nested `schema` fields validate recursively — their errors nest inside the parent `FieldError.errors`.
|
|
297
|
+
|
|
298
|
+
### Non-throwing checks
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
import { areSchemaValuesValid, isSchemaValid, validateSchema } from '@sprucelabs/schema'
|
|
302
|
+
|
|
303
|
+
areSchemaValuesValid(personSchema, formValues) // boolean — values check
|
|
304
|
+
areSchemaValuesValid(personSchema, formValues, { fields: ['firstName'] })
|
|
305
|
+
|
|
306
|
+
isSchemaValid(maybeSchema) // type guard — is this object a valid *schema*?
|
|
307
|
+
validateSchema(maybeSchema) // asserting version — throws INVALID_SCHEMA
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
`validateSchema` checks the schema shell itself (`id` present and a string, `name` a string if present, and one of `fields`/`dynamicFieldSignature` set) and reports codes like `id_missing` and `needs_fields_or_dynamic_field_signature` inside the thrown error.
|
|
311
|
+
|
|
312
|
+
### Reading validation errors
|
|
313
|
+
|
|
314
|
+
The thrown error's `options.errors` is a tree of `FieldError`s:
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
interface FieldError {
|
|
318
|
+
code: 'MISSING_PARAMETER' | 'INVALID_PARAMETER' | 'UNEXPECTED_PARAMETER'
|
|
319
|
+
name: string // field name
|
|
320
|
+
label?: string
|
|
321
|
+
friendlyMessage?: string
|
|
322
|
+
originalError?: Error
|
|
323
|
+
errors?: FieldError[] // nested schema fields nest here
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
`err.message` renders a numbered, human-friendly list (dotted names for nested fields):
|
|
328
|
+
|
|
329
|
+
```
|
|
330
|
+
'person' has 2 errors!
|
|
331
|
+
|
|
332
|
+
1. 'firstName' is required
|
|
333
|
+
2. (requiredCar.name) 'This is required!'
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
To convert field errors into the aggregate parameter-error style used across the Spruce platform:
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
import { mapFieldErrorsToParameterErrors } from '@sprucelabs/schema'
|
|
340
|
+
|
|
341
|
+
const errors = mapFieldErrorsToParameterErrors(err.options.errors)
|
|
342
|
+
// up to 3 SchemaErrors: MISSING_PARAMETERS / INVALID_PARAMETERS / UNEXPECTED_PARAMETERS,
|
|
343
|
+
// each with parameters: ['firstName', 'source.organizationId', ...]
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## Normalizing values
|
|
349
|
+
|
|
350
|
+
### `normalizeSchemaValues(schema, values, options?)`
|
|
351
|
+
|
|
352
|
+
Runs loose input through each field's transformer and hands back clean, typed values:
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
const person = normalizeSchemaValues(personSchema, {
|
|
356
|
+
firstName: 12345, // -> '12345'
|
|
357
|
+
age: '10', // -> 10
|
|
358
|
+
boolean: 'false', // -> false
|
|
359
|
+
phone: '5555555555', // -> '+1 555-555-5555'
|
|
360
|
+
})
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
Options (all optional):
|
|
364
|
+
|
|
365
|
+
| Option | Default | What it does |
|
|
366
|
+
|---|---|---|
|
|
367
|
+
| `shouldValidate` | `true` | Validate while normalizing (throws on bad values) |
|
|
368
|
+
| `shouldCreateEntityInstances` | `false` | Return entity instances for `schema` fields instead of plain objects |
|
|
369
|
+
| `fields` | all | Only include these fields (dot paths allowed — acts as a deep pick) |
|
|
370
|
+
| `excludeFields` | — | Drop these fields |
|
|
371
|
+
| `shouldIncludePrivateFields` | `true` | Pass literal `false` to strip `isPrivate` fields — the return **type** narrows too |
|
|
372
|
+
| `shouldIncludeNullAndUndefinedFields` | `true` | Pass `false` to omit unset/null keys entirely |
|
|
373
|
+
| `byField` | — | Per-field option overrides, e.g. `{ name: { maxLength: 10 } }` |
|
|
374
|
+
| `shouldRetainDotNotationKeys` | `false` | Keep output flat with dotted keys instead of nested |
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
// public projection
|
|
378
|
+
const publicPerson = normalizeSchemaValues(personSchema, record, {
|
|
379
|
+
shouldIncludePrivateFields: false,
|
|
380
|
+
fields: ['firstName', 'lastName'],
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
// per-call field option override — truncate on read
|
|
384
|
+
normalizeSchemaValues(personSchema, values, {
|
|
385
|
+
byField: { firstName: { maxLength: 10 } },
|
|
386
|
+
})
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
---
|
|
390
|
+
|
|
391
|
+
## Default values
|
|
392
|
+
|
|
393
|
+
Fields that declare `defaultValue` can be materialized in one call:
|
|
394
|
+
|
|
395
|
+
```ts
|
|
396
|
+
import { defaultSchemaValues } from '@sprucelabs/schema'
|
|
397
|
+
|
|
398
|
+
defaultSchemaValues(kitchenSinkSchema)
|
|
399
|
+
// { status: 'draft', lengthOfBooking: { hours: 1, ... } }
|
|
400
|
+
// only fields WITH a defaultValue appear — and the type knows that:
|
|
401
|
+
// SchemaDefaultValues<S> picks exactly the defaulted field names
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
Note the difference between two similarly-named field options:
|
|
405
|
+
|
|
406
|
+
- `defaultValue` — only surfaced by `defaultSchemaValues()` / `entity.getDefaultValues()`.
|
|
407
|
+
- `value` — a hardcoded initial value that is `set()` automatically when an entity is constructed.
|
|
408
|
+
|
|
409
|
+
---
|
|
410
|
+
|
|
411
|
+
## Schema entities
|
|
412
|
+
|
|
413
|
+
Entities wrap a schema + values with a live API. Build them via the factory (it picks static vs dynamic for you):
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
import { SchemaEntityFactory } from '@sprucelabs/schema'
|
|
417
|
+
|
|
418
|
+
const person = SchemaEntityFactory.Entity(personSchema, { firstName: 'Tay' })
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
Or directly — `StaticSchemaEntityImpl` is also the package's default export:
|
|
422
|
+
|
|
423
|
+
```ts
|
|
424
|
+
import StaticSchemaEntityImpl from '@sprucelabs/schema'
|
|
425
|
+
|
|
426
|
+
const person = new StaticSchemaEntityImpl(personSchema, { firstName: 'Tay' })
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
### The API
|
|
430
|
+
|
|
431
|
+
```ts
|
|
432
|
+
person.get('firstName') // 'Tay' — normalized on read
|
|
433
|
+
person.set('firstName', 'Taylor') // normalized + validated on write; chainable
|
|
434
|
+
person.setValues({ firstName: 'Becca', age: 30 })
|
|
435
|
+
|
|
436
|
+
person.getValues() // everything, validated, entity instances for nested schemas
|
|
437
|
+
person.getValues({ shouldValidate: false }) // safe read of a partially-filled entity
|
|
438
|
+
person.getValues({ shouldCreateEntityInstances: false }) // plain objects for nested schemas
|
|
439
|
+
person.getValues({ shouldIncludePrivateFields: false }) // strip isPrivate fields
|
|
440
|
+
person.getValues({ fields: ['firstName'] }) // subset
|
|
441
|
+
person.getValues({ excludeFields: ['age'] })
|
|
442
|
+
person.getValues({ shouldIncludeNullAndUndefinedFields: false }) // omit unset keys
|
|
443
|
+
|
|
444
|
+
person.validate() // throws VALIDATION_FAILED with all field errors
|
|
445
|
+
person.isValid() // boolean
|
|
446
|
+
person.getDefaultValues() // fields with defaultValue, normalized
|
|
447
|
+
|
|
448
|
+
// introspection
|
|
449
|
+
for (const { name, field } of person.getNamedFields()) {
|
|
450
|
+
console.log(name, field.type, field.isRequired, field.label, field.hint)
|
|
451
|
+
}
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
Per-call field option overrides work on `get` too:
|
|
455
|
+
|
|
456
|
+
```ts
|
|
457
|
+
person.set('firstName', 'a really long name that should get truncated')
|
|
458
|
+
person.get('firstName', { byField: { firstName: { maxLength: 10 } } }) // 'a really l'
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
Transformation happens **on read and write**:
|
|
462
|
+
|
|
463
|
+
```ts
|
|
464
|
+
entity.set('favoriteColors', [1, 2, 3]) // text[] -> ['1', '2', '3']
|
|
465
|
+
entity.set('age', ['9', '8']) // non-array field + array input -> takes [0] -> 9
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
⚠️ `get`/`getValues` validate by default — reading a required-but-unset field throws `MISSING_PARAMETERS`. Pass `{ shouldValidate: false }` for a safe read of an incomplete entity.
|
|
469
|
+
|
|
470
|
+
### `FieldFactory`
|
|
471
|
+
|
|
472
|
+
Build a single field instance when you need one outside a schema:
|
|
473
|
+
|
|
474
|
+
```ts
|
|
475
|
+
import { FieldFactory } from '@sprucelabs/schema'
|
|
476
|
+
|
|
477
|
+
const field = FieldFactory.Field('firstName', { type: 'text', isRequired: true })
|
|
478
|
+
field.validate(undefined) // [{ code: 'MISSING_PARAMETER', name: 'firstName' }]
|
|
479
|
+
field.toValueType(123) // '123'
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
---
|
|
483
|
+
|
|
484
|
+
## Dynamic schemas
|
|
485
|
+
|
|
486
|
+
When keys aren't known ahead of time but all values share one shape, use `dynamicFieldSignature` instead of `fields`:
|
|
487
|
+
|
|
488
|
+
```ts
|
|
489
|
+
const scoresSchema = buildSchema({
|
|
490
|
+
id: 'scores',
|
|
491
|
+
name: 'Scores by name',
|
|
492
|
+
dynamicFieldSignature: { type: 'number', keyName: 'name', isRequired: true },
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
const scores = SchemaEntityFactory.Entity(scoresSchema, { taylor: 10, kayla: 12 })
|
|
496
|
+
scores.set('anyKeyAtAll', 5)
|
|
497
|
+
scores.getValues() // Record<string, number>
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
- The generated type follows the signature: optional → `{ [key: string]?: number }`, `isRequired: true` → `Record<string, number>`, `isArray: true` → `Record<string, number[]>`.
|
|
501
|
+
- `keyName` names the key in generated code/docs; `keyTypeLiteral` can narrow the key type during codegen. Neither is enforced at runtime.
|
|
502
|
+
- `normalizeSchemaValues` and `validateSchemaValues` work on dynamic schemas too (the factory picks `DynamicSchemaEntityImplementation` under the hood).
|
|
503
|
+
|
|
504
|
+
---
|
|
505
|
+
|
|
506
|
+
## Nested schemas & relationships
|
|
507
|
+
|
|
508
|
+
The `schema` field type maps relationships. Four ways to point at the related schema(s):
|
|
509
|
+
|
|
510
|
+
```ts
|
|
511
|
+
// 1. inline — full type inference flows through
|
|
512
|
+
address: { type: 'schema', isRequired: true, options: { schema: addressSchema } }
|
|
513
|
+
|
|
514
|
+
// 2. by id (+ optional version/namespace) — resolved via the SchemaRegistry at runtime
|
|
515
|
+
person: { type: 'schema', options: { schemaId: { id: 'person', version: 'v2020_07_22' } } }
|
|
516
|
+
|
|
517
|
+
// 3. union of schemas — values carry a discriminator
|
|
518
|
+
vehicle: { type: 'schema', options: { schemas: [carSchema, truckSchema] } }
|
|
519
|
+
|
|
520
|
+
// 4. lazy callback — for circular references
|
|
521
|
+
friend: { type: 'schema', options: { schemasCallback: () => [personSchema] } }
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
Arrays of relationships are just `isArray: true` on top:
|
|
525
|
+
|
|
526
|
+
```ts
|
|
527
|
+
cars: { type: 'schema', isArray: true, minArrayLength: 0, options: { schema: carSchema } }
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
**Union values** are shaped `{ id, version?, values }` so the library knows which schema to validate against:
|
|
531
|
+
|
|
532
|
+
```ts
|
|
533
|
+
person.set('vehicle', { id: 'car', values: { name: 'The Go-Kart' } })
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
**Reading nested values** — by default entities hydrate nested schemas into entity instances; utilities return plain objects:
|
|
537
|
+
|
|
538
|
+
```ts
|
|
539
|
+
const car = person.get('requiredCar') // StaticSchemaEntity — has .get()/.set()
|
|
540
|
+
car.get('name')
|
|
541
|
+
|
|
542
|
+
person.get('requiredCar', { shouldCreateEntityInstances: false }) // plain object
|
|
543
|
+
normalizeSchemaValues(personSchema, values) // plain objects (its default is false)
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
Nested validation errors nest: the parent field gets one `INVALID_PARAMETER` whose `errors` array holds the child's field errors, rendered with dotted names (`requiredCar.name`).
|
|
547
|
+
|
|
548
|
+
> Only inline `schema`/`schemas` options produce precise generated types — `schemaId`/`schemaIds` resolve to `any` at the type level and rely on the registry/codegen to fill in types.
|
|
549
|
+
|
|
550
|
+
---
|
|
551
|
+
|
|
552
|
+
## Dot notation
|
|
553
|
+
|
|
554
|
+
Several APIs speak dot notation for nested values:
|
|
555
|
+
|
|
556
|
+
```ts
|
|
557
|
+
import { flattenValues, expandValues } from '@sprucelabs/schema'
|
|
558
|
+
|
|
559
|
+
// normalize expands dotted input keys into nested objects
|
|
560
|
+
normalizeSchemaValues(eventSchema, {
|
|
561
|
+
firstName: 'bob',
|
|
562
|
+
'source.organizationId': orgId,
|
|
563
|
+
})
|
|
564
|
+
// -> { firstName: 'bob', source: { organizationId: orgId, ... } }
|
|
565
|
+
|
|
566
|
+
// keep the output flat instead
|
|
567
|
+
normalizeSchemaValues(eventSchema, values, { shouldRetainDotNotationKeys: true })
|
|
568
|
+
|
|
569
|
+
// dotted `fields` entries act as a deep pick
|
|
570
|
+
normalizeSchemaValues(eventSchema, values, { fields: ['source.organizationId'] })
|
|
571
|
+
// -> { source: { organizationId } }
|
|
572
|
+
|
|
573
|
+
// the raw pair
|
|
574
|
+
flattenValues({ a: { b: { c: 1 } } }) // { 'a.b.c': 1 }
|
|
575
|
+
expandValues({ 'a.b.c': 1 }) // { a: { b: { c: 1 } } }
|
|
576
|
+
|
|
577
|
+
// flattenValues ignore rules
|
|
578
|
+
flattenValues(values, ['payload']) // leave the payload subtree nested
|
|
579
|
+
flattenValues(values, ['*.organizationId']) // wildcard: keep any organizationId leaf grouped
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
`validateSchemaValues` also accepts dotted input keys, and `assertOptions` + `validationErrorAssert` accept dotted paths/names.
|
|
583
|
+
|
|
584
|
+
---
|
|
585
|
+
|
|
586
|
+
## The schema registry & versioning
|
|
587
|
+
|
|
588
|
+
`buildSchema()` tracks every schema in a process-wide singleton so `schemaId` references and codegen can find them:
|
|
589
|
+
|
|
590
|
+
```ts
|
|
591
|
+
import { SchemaRegistry, buildSchema } from '@sprucelabs/schema'
|
|
592
|
+
|
|
593
|
+
buildSchema({ id: 'wrench', version: 'v1', fields: { length: { type: 'number' } } })
|
|
594
|
+
buildSchema({ id: 'wrench', version: 'v2', fields: { length: { type: 'number' }, diameter: { type: 'number' } } })
|
|
595
|
+
|
|
596
|
+
const registry = SchemaRegistry.getInstance()
|
|
597
|
+
|
|
598
|
+
registry.getSchema('wrench', 'v2') // exact version match
|
|
599
|
+
registry.getSchema('wrench') // throws VERSION_NOT_FOUND (versions exist, none picked)
|
|
600
|
+
registry.getSchema('nope') // throws SCHEMA_NOT_FOUND
|
|
601
|
+
registry.isTrackingSchema('wrench', 'v1') // true
|
|
602
|
+
registry.getAllSchemas()
|
|
603
|
+
registry.getTrackingCount()
|
|
604
|
+
registry.forgetSchema('wrench', 'v1')
|
|
605
|
+
registry.forgetAllSchemas() // typical test beforeEach
|
|
606
|
+
SchemaRegistry.reset() // drop the singleton entirely
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
Things to know:
|
|
610
|
+
|
|
611
|
+
- **`DUPLICATE_SCHEMA`** is thrown when the same `(id, version, namespace)` is tracked twice — the classic symptom is a module calling `buildSchema()` at module scope getting imported twice, or test files sharing fixtures. Fix by calling `forgetAllSchemas()` in `beforeEach` (see [Testing helpers](#testing-helpers)).
|
|
612
|
+
- Tracking can be disabled entirely with the env var `SHOULD_USE_SCHEMA_REGISTRY=false`. It's read once at first `getInstance()` — call `SchemaRegistry.reset()` if you change it mid-process.
|
|
613
|
+
- Version matching is **exact**; a versionless schema and versioned schemas with the same id can coexist.
|
|
614
|
+
- Namespaces filter lookups when passed; when omitted, all namespaces are candidates.
|
|
615
|
+
|
|
616
|
+
Related identity helpers:
|
|
617
|
+
|
|
618
|
+
```ts
|
|
619
|
+
import { isIdWithVersion, normalizeSchemaToIdWithVersion, areSchemasTheSame } from '@sprucelabs/schema'
|
|
620
|
+
|
|
621
|
+
isIdWithVersion({ id: 'person' }) // true — a reference, not a full schema
|
|
622
|
+
normalizeSchemaToIdWithVersion(personSchema) // { id: 'person' } (+ version/namespace when set)
|
|
623
|
+
areSchemasTheSame(a, b) // shallow: compares id + sorted field names only
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
---
|
|
627
|
+
|
|
628
|
+
## TypeScript type helpers
|
|
629
|
+
|
|
630
|
+
All exported from the package root. The big ones:
|
|
631
|
+
|
|
632
|
+
| Type | What you get |
|
|
633
|
+
|---|---|
|
|
634
|
+
| `SchemaValues<S>` | The natural data type — required fields required, optional fields `?: T \| null` |
|
|
635
|
+
| `SchemaValues<S, true>` | Same, but `schema` fields become entity instances |
|
|
636
|
+
| `SchemaPartialValues<S>` | Everything optional and nullable — constructor/`setValues` input |
|
|
637
|
+
| `SchemaAllValues<S>` | Every key present (`-?`), values still nullable when optional |
|
|
638
|
+
| `SchemaDefaultValues<S>` | Only fields with `defaultValue`, non-nullable |
|
|
639
|
+
| `SchemaValuesWithDefaults<S>` | `SchemaValues & SchemaDefaultValues` |
|
|
640
|
+
| `SchemaPublicValues<S>` | `isPrivate` fields removed |
|
|
641
|
+
| `SchemaFieldNames<S>` | Union of field names |
|
|
642
|
+
| `SchemaRequiredFieldNames<S>` / `SchemaOptionalFieldNames<S>` | Name unions by required-ness |
|
|
643
|
+
| `SchemaFieldNamesWithDefaultValue<S>` | Names of defaulted fields |
|
|
644
|
+
| `SchemaPublicFieldNames<S>` | Non-private names |
|
|
645
|
+
| `PickFieldNames<S, 'select'>` | Names of fields of a given type |
|
|
646
|
+
| `SchemaFieldValueType<S, 'fieldName'>` | One field's value type |
|
|
647
|
+
| `SchemaIdWithVersion` | `{ id, version?, namespace? }` |
|
|
648
|
+
| `SchemaEntity` / `StaticSchemaEntity<S>` / `DynamicSchemaEntityByName<S>` | Entity contracts |
|
|
649
|
+
| `IsDynamicSchema<S>` | Type-level static/dynamic test |
|
|
650
|
+
| `Optional<T>` | Every property `?: T \| null` — matches how schemas express optionality |
|
|
651
|
+
| `Unpack<T>` / `IsArray<T, B>` / `IsRequired<T, B>` | The primitives field value types are built from |
|
|
652
|
+
| `ValuesWithPaths<T>` / `PathsWithDotNotation<T>` / `TypeAtPath<T, P>` | Dot-notation typing (depth 3) |
|
|
653
|
+
|
|
654
|
+
```ts
|
|
655
|
+
type Person = SchemaValues<typeof personSchema>
|
|
656
|
+
type PersonPatch = SchemaPartialValues<typeof personSchema>
|
|
657
|
+
type PublicPerson = SchemaValues<typeof personSchema, false, false>
|
|
658
|
+
type NameField = SchemaFieldValueType<typeof personSchema, 'firstName'> // string
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
---
|
|
662
|
+
|
|
663
|
+
## Errors
|
|
664
|
+
|
|
665
|
+
Everything throws `SchemaError` (a `SpruceError` from `@sprucelabs/error`) with a typed `options.code`. All option shapes are exported from the root (`FieldError`, `FieldErrorCode`, `ValidationError`, `SchemaErrorOptions`, and every `*Options` interface).
|
|
666
|
+
|
|
667
|
+
| Code | Thrown when | Key options |
|
|
668
|
+
|---|---|---|
|
|
669
|
+
| `VALIDATION_FAILED` | Values fail validation (`validateSchemaValues`, `entity.validate/set/getValues`) | `schemaId`, `schemaName?`, `errors: FieldError[]` |
|
|
670
|
+
| `INVALID_SCHEMA` | Schema shell is malformed (`validateSchema`, registry tracking) | `schemaId`, `errors: string[]` |
|
|
671
|
+
| `DUPLICATE_SCHEMA` | Same `(id, version, namespace)` tracked twice | `schemaId`, `version?`, `namespace?` |
|
|
672
|
+
| `SCHEMA_NOT_FOUND` | Registry lookup missed the id | `schemaId`, `version?`, `namespace?` |
|
|
673
|
+
| `VERSION_NOT_FOUND` | Id found, version didn't disambiguate | `schemaId?`, `namespace?` |
|
|
674
|
+
| `TRANSFORMATION_ERROR` | A value can't be coerced (`text`/`number`/`directory`/`schema`) | `fieldType`, `incomingTypeof`, `incomingValue`, `name` |
|
|
675
|
+
| `MISSING_PARAMETERS` | `assertOptions` missing paths; reading unset required fields | `parameters: string[]`, `friendlyMessages?` |
|
|
676
|
+
| `INVALID_PARAMETERS` | Bad inputs to utilities (`getFields`, duration parsing, …) | `parameters: string[]` |
|
|
677
|
+
| `UNEXPECTED_PARAMETERS` | Values for fields that don't exist | `parameters: string[]` |
|
|
678
|
+
| `FIELDS_NOT_MAPPED` | `KeyMapper` hit keys not in its map | `fields: string[]` |
|
|
679
|
+
| `INVALID_FIELD_REGISTRATION` | `registerFieldType` given a bad registration | `package`, `className`, `type`, `importAs`, `description` |
|
|
680
|
+
| `INVALID_SCHEMA_REFERENCE` | Codegen: schema reference missing a namespace | (friendlyMessage) |
|
|
681
|
+
| `NOT_IMPLEMENTED` | Custom field missing `generateTemplateDetails()` | `instructions` (a copy/paste stub) |
|
|
682
|
+
|
|
683
|
+
Note the singular/plural split: `FieldError.code` values are singular (`MISSING_PARAMETER`) and live *inside* a `VALIDATION_FAILED`; the plural codes are top-level aggregate errors. `mapFieldErrorsToParameterErrors()` bridges from the first to the second.
|
|
684
|
+
|
|
685
|
+
```ts
|
|
686
|
+
import { SchemaError } from '@sprucelabs/schema'
|
|
687
|
+
|
|
688
|
+
try {
|
|
689
|
+
validateSchemaValues(personSchema, values)
|
|
690
|
+
} catch (err) {
|
|
691
|
+
if (err instanceof SchemaError && err.options.code === 'VALIDATION_FAILED') {
|
|
692
|
+
console.log(err.message) // friendly numbered list
|
|
693
|
+
console.log(err.options.errors) // FieldError tree
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
---
|
|
699
|
+
|
|
700
|
+
## Utilities
|
|
701
|
+
|
|
702
|
+
### `assertOptions(options, paths, friendlyMessage?)`
|
|
703
|
+
|
|
704
|
+
The guard-clause utility used all over the Spruce platform. Checks that each path (dot notation supported, depth 3) is not `null`/`undefined` — falsy-but-present values like `0` and `''` pass. Throws one `MISSING_PARAMETERS` listing everything missing, and **returns `options` with the checked paths narrowed to `NonNullable`**:
|
|
705
|
+
|
|
706
|
+
```ts
|
|
707
|
+
import { assertOptions } from '@sprucelabs/schema'
|
|
708
|
+
|
|
709
|
+
const { organizationId } = assertOptions(options, ['organizationId', 'nested.hey'])
|
|
710
|
+
organizationId // string — narrowed from string | null | undefined
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
### `KeyMapper`
|
|
714
|
+
|
|
715
|
+
Rename keys between two shapes (e.g. your schema ↔ a third-party API):
|
|
716
|
+
|
|
717
|
+
```ts
|
|
718
|
+
import { KeyMapper } from '@sprucelabs/schema'
|
|
719
|
+
|
|
720
|
+
const mapper = new KeyMapper({ firstName: 'given_name', lastName: 'family_name' })
|
|
721
|
+
|
|
722
|
+
mapper.mapTo({ firstName: 'Tay' }) // { given_name: 'Tay' }
|
|
723
|
+
mapper.mapFrom({ given_name: 'Tay' }) // { firstName: 'Tay' }
|
|
724
|
+
mapper.mapFieldNameTo('firstName') // 'given_name'
|
|
725
|
+
mapper.mapFieldNameFrom('family_name') // 'lastName'
|
|
726
|
+
|
|
727
|
+
mapper.mapTo({ nickname: 'T' }) // throws FIELDS_NOT_MAPPED { fields: ['nickname'] }
|
|
728
|
+
mapper.mapTo({ nickname: 'T' }, { shouldThrowOnUnmapped: false }) // {} — drops silently
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
### Cloning
|
|
732
|
+
|
|
733
|
+
```ts
|
|
734
|
+
import { cloneDeep, cloneDeepPreservingInstances } from '@sprucelabs/schema'
|
|
735
|
+
|
|
736
|
+
cloneDeep(value) // deep clone: objects, arrays, Map, Set, Date, RegExp
|
|
737
|
+
cloneDeepPreservingInstances(value) // same, but class instances pass by reference
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
Entities use `cloneDeepPreservingInstances` internally, so nested entity instances survive construction intact.
|
|
741
|
+
|
|
742
|
+
### Phone numbers
|
|
743
|
+
|
|
744
|
+
```ts
|
|
745
|
+
import { formatPhoneNumber, isValidNumber, isDummyNumber } from '@sprucelabs/schema'
|
|
746
|
+
|
|
747
|
+
formatPhoneNumber('5555555555') // '+1 555-555-5555' (default country +1)
|
|
748
|
+
formatPhoneNumber('+49 170 1234567') // '+49 170 123 4567' (also +92, +90 built in)
|
|
749
|
+
formatPhoneNumber('nope') // 'nope' — fails silently by default
|
|
750
|
+
formatPhoneNumber('nope', false) // throws Error('INVALID_PHONE_NUMBER')
|
|
751
|
+
|
|
752
|
+
isValidNumber('555-555-5555') // true
|
|
753
|
+
isDummyNumber('+1 555-555-5555') // true — 555/1555 numbers, handy in tests
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
### Select choices
|
|
757
|
+
|
|
758
|
+
```ts
|
|
759
|
+
import { selectChoicesToHash, schemaChoicesToHash } from '@sprucelabs/schema'
|
|
760
|
+
|
|
761
|
+
selectChoicesToHash([{ value: 'sm', label: 'Small' }]) // { sm: 'Small' }
|
|
762
|
+
schemaChoicesToHash(shirtSchema, 'size') // reads choices off a select field, fully typed
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
---
|
|
766
|
+
|
|
767
|
+
## Testing helpers
|
|
768
|
+
|
|
769
|
+
Ships with assertion helpers (via the runtime dependency `@sprucelabs/test-utils`):
|
|
770
|
+
|
|
771
|
+
### `validationErrorAssert`
|
|
772
|
+
|
|
773
|
+
Asserts *which fields* inside a `VALIDATION_FAILED` were missing/invalid/unexpected — dot notation reaches into nested schemas:
|
|
774
|
+
|
|
775
|
+
```ts
|
|
776
|
+
import { validationErrorAssert } from '@sprucelabs/schema'
|
|
777
|
+
import { assert } from '@sprucelabs/test-utils'
|
|
778
|
+
|
|
779
|
+
const err = assert.doesThrow(() => validateSchemaValues(personSchema, {}))
|
|
780
|
+
|
|
781
|
+
validationErrorAssert.assertError(err, {
|
|
782
|
+
missing: ['firstName', 'requiredCar.name'],
|
|
783
|
+
invalid: ['age'],
|
|
784
|
+
unexpected: ['whoops'],
|
|
785
|
+
})
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
Pair it with `errorAssert` from `@sprucelabs/test-utils` for the outer error code:
|
|
789
|
+
|
|
790
|
+
```ts
|
|
791
|
+
errorAssert.assertError(err, 'VALIDATION_FAILED')
|
|
792
|
+
errorAssert.assertError(err, 'MISSING_PARAMETERS', { parameters: ['organizationId'] })
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
### `selectAssert`
|
|
796
|
+
|
|
797
|
+
```ts
|
|
798
|
+
import { selectAssert } from '@sprucelabs/schema'
|
|
799
|
+
|
|
800
|
+
selectAssert.assertSelectChoicesMatch(field.options.choices, ['draft', 'live'])
|
|
801
|
+
// order-insensitive, compares values only
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
(`selectAssertUtil` is a deprecated alias.)
|
|
805
|
+
|
|
806
|
+
### `AbstractSchemaTest`
|
|
807
|
+
|
|
808
|
+
A test base class that resets the schema registry before each test (avoiding `DUPLICATE_SCHEMA` bleed) and exposes `this.registry`. Note it isn't exported from the package root — extend the same two-line pattern in your own base class:
|
|
809
|
+
|
|
810
|
+
```ts
|
|
811
|
+
protected static async beforeEach() {
|
|
812
|
+
await super.beforeEach()
|
|
813
|
+
SchemaRegistry.getInstance().forgetAllSchemas()
|
|
814
|
+
}
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
---
|
|
818
|
+
|
|
819
|
+
## Custom field types
|
|
820
|
+
|
|
821
|
+
Fields are pluggable. Subclass `AbstractField`, describe yourself, and register:
|
|
822
|
+
|
|
823
|
+
```ts
|
|
824
|
+
import { AbstractField, FieldDefinition, registerFieldType } from '@sprucelabs/schema'
|
|
825
|
+
|
|
826
|
+
type ColorFieldDefinition = FieldDefinition<string> & {
|
|
827
|
+
type: 'color'
|
|
828
|
+
options?: { allowAlpha?: boolean }
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
class ColorField extends AbstractField<ColorFieldDefinition> {
|
|
832
|
+
public static readonly description = 'A CSS color value.'
|
|
833
|
+
|
|
834
|
+
public static generateTemplateDetails() {
|
|
835
|
+
return { valueType: 'string' }
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
public validate(value: any) {
|
|
839
|
+
const errors = super.validate(value) // required check
|
|
840
|
+
if (value && !isValidCssColor(value)) {
|
|
841
|
+
errors.push({
|
|
842
|
+
code: 'INVALID_PARAMETER',
|
|
843
|
+
name: this.name,
|
|
844
|
+
friendlyMessage: `'${value}' is not a valid color!`,
|
|
845
|
+
})
|
|
846
|
+
}
|
|
847
|
+
return errors
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
public toValueType(value: any) {
|
|
851
|
+
return `${value}`.toLowerCase()
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
export default registerFieldType({
|
|
856
|
+
type: 'Color', // PascalCase — becomes the enum key in generated code
|
|
857
|
+
class: ColorField,
|
|
858
|
+
package: '@my-org/my-fields',
|
|
859
|
+
importAs: 'MyFields',
|
|
860
|
+
})
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
- `static description` is required (the base class ships a nag message if you forget).
|
|
864
|
+
- `static generateTemplateDetails()` tells codegen what type to render; forget it and you get a helpful `NOT_IMPLEMENTED` error containing a copy/paste stub.
|
|
865
|
+
- `static generateTypeDetails()` (optional) supplies a `valueTypeMapper` for advanced generic types — see `SelectField`/`SchemaField` for the pattern.
|
|
866
|
+
- Note the case split: the addon/registration `type` is PascalCase (`'DateTime'`), while the `type` string used in field definitions is camelCase (`'dateTime'`).
|
|
867
|
+
|
|
868
|
+
All 16 built-in registrations are exported as `fieldRegistrations: FieldRegistration[]` — that array is what the Spruce CLI consumes to generate field enums and class maps in consuming projects.
|
|
869
|
+
|
|
870
|
+
---
|
|
871
|
+
|
|
872
|
+
## Code generation
|
|
873
|
+
|
|
874
|
+
The library carries everything needed to render schemas into source code:
|
|
875
|
+
|
|
876
|
+
- **`SchemaTypesRenderer`** — renders a schema into a Go struct (one struct per call; nested schemas need their own calls):
|
|
877
|
+
|
|
878
|
+
```ts
|
|
879
|
+
import { SchemaTypesRenderer } from '@sprucelabs/schema'
|
|
880
|
+
|
|
881
|
+
SchemaTypesRenderer.Renderer().render(schema, {
|
|
882
|
+
language: 'go',
|
|
883
|
+
schemaTemplateItems: [],
|
|
884
|
+
})
|
|
885
|
+
// type Person struct {
|
|
886
|
+
// FirstName string `json:"firstName" validate:"required"`
|
|
887
|
+
// Age float64 `json:"age,omitempty"`
|
|
888
|
+
// }
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
Required fields get `validate:"required"` tags, arrays get `min=`/`dive`, `hint`s become comments, and optional fields get `,omitempty`.
|
|
892
|
+
|
|
893
|
+
- **Template types** — `TemplateRenderAs` (enum: `Type` / `Value` / `SchemaType`), `SchemaTemplateItem`, `FieldTemplateItem`, `FieldTemplateDetails`, `TemplateLanguage` (`'ts' | 'go'`) are all exported for tooling that renders TypeScript types (the Spruce CLI uses these plus each field's `generateTemplateDetails()`).
|
|
894
|
+
- Schema-level codegen hints: `importsWhenLocal`, `importsWhenRemote`, `moduleToImportFromWhenRemote`, `typeSuffix` (e.g. `'<T>'` for generic passthrough).
|
|
895
|
+
|
|
896
|
+
---
|
|
897
|
+
|
|
898
|
+
## Gotchas & good-to-knows
|
|
899
|
+
|
|
900
|
+
1. **Required array fields default to `minArrayLength: 1`** — `[]` fails. Set `minArrayLength: 0` to allow empties.
|
|
901
|
+
2. **`get`/`getValues` validate by default** — reading an entity with unset required fields throws. Use `{ shouldValidate: false }` for partial reads.
|
|
902
|
+
3. **`shouldCreateEntityInstances` defaults differ by entry point**: `true` on entities (`get`/`getValues`), `false` in `normalizeSchemaValues`/`defaultSchemaValues`. The return *types* change with it.
|
|
903
|
+
4. **`DUPLICATE_SCHEMA` in tests** almost always means module-scope `buildSchema()` calls being re-imported — reset the registry in `beforeEach` (or set `SHOULD_USE_SCHEMA_REGISTRY=false`).
|
|
904
|
+
5. **`getSchema(id)` with no version throws `VERSION_NOT_FOUND`** when only versioned entries exist — version matching is exact.
|
|
905
|
+
6. **`setValues` can't clear a field with `undefined`** (it's skipped) — pass `null`. On dynamic entities, `setValues` is a shallow merge that skips normalization entirely (normalization happens on `get`).
|
|
906
|
+
7. **Setting an array on a non-array field keeps only element `[0]`** silently.
|
|
907
|
+
8. **`value` vs `defaultValue`**: `value` is applied at construction; `defaultValue` only surfaces through the default-values APIs.
|
|
908
|
+
9. **Union schema-field values use `id`**, i.e. `{ id: 'car', values: {...} }` (add `version` to disambiguate).
|
|
909
|
+
10. **Dynamic entities only know keys that have values** — an empty dynamic entity validates clean even with `isRequired: true` on the signature.
|
|
910
|
+
11. **`areSchemasTheSame` compares id + field names only** — not versions, namespaces, or field definitions.
|
|
911
|
+
12. **Not exported from the package root**: `DynamicSchemaEntityImplementation` (use `SchemaEntityFactory`), `AbstractEntity`, `AbstractSchemaTest`, `normalizePartialSchemaValues`. The `exports` map only exposes `"."`, so deep imports aren't available under modern module resolution.
|
|
912
|
+
13. **Declared-but-not-enforced options** (documented for codegen, no runtime check yet): `text.minLength`, `number.min`/`max` (enforced in generated Go via validate tags), `duration.minDuration`/`maxDuration`.
|
|
913
|
+
14. **`entity.namespace` and `entity.description` are currently unreliable** (they return `name` and `id` respectively) — read `schema.namespace`/`schema.description` off the schema itself.
|
|
914
|
+
15. **`SchemaValidateOptions.shouldMapToParameterErrors` is currently a no-op** — call `mapFieldErrorsToParameterErrors()` yourself.
|
|
915
|
+
|
|
916
|
+
---
|
|
917
|
+
|
|
918
|
+
## Contributing / local development
|
|
919
|
+
|
|
920
|
+
```bash
|
|
921
|
+
yarn # install
|
|
922
|
+
yarn build.dev # compile to build/ (tests run against build/, not src/!)
|
|
923
|
+
yarn test # jest — remember to build first
|
|
924
|
+
yarn watch.build.dev # recompile on change
|
|
925
|
+
yarn lint # eslint
|
|
926
|
+
yarn lint.tsc # type-check without emitting
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
- **Tests execute compiled JS in `build/`** (`testMatch: **/__tests__/**/*.test.js`) — if a test change doesn't seem to take effect, you forgot to rebuild (or aren't running the watcher).
|
|
930
|
+
- `#spruce/*` imports map to `build/.spruce/*` (generated field maps/types).
|
|
931
|
+
- `yarn build.dist` produces the dual CJS (`build/`) + ESM (`build/esm/`) publish layout.
|
|
932
|
+
- Releases go out via `semantic-release` (`release.config.cjs`).
|
|
933
|
+
- The behavioral test suite (`src/__tests__/behavioral/`) doubles as living documentation — nearly every feature above has a matching test file.
|
|
21
934
|
|
|
22
935
|
### Dependencies
|
|
23
936
|
|
|
@@ -129,9 +129,13 @@ class StaticSchemaEntityImpl extends AbstractEntity_1.default {
|
|
|
129
129
|
const isMissingRequiredOrMinValues = field.isRequired &&
|
|
130
130
|
field.isArray &&
|
|
131
131
|
(!value || valueAsArray.length < (field.minArrayLength ?? 1));
|
|
132
|
+
const hasTooManyValues = !!field.maxArrayLength &&
|
|
133
|
+
valueAsArray.length > field.maxArrayLength;
|
|
132
134
|
const isMissing = !value;
|
|
133
135
|
const shouldBeArrayButIsnt = !wasArray && !isMissing && field.isArray;
|
|
134
|
-
if (isMissingRequiredOrMinValues ||
|
|
136
|
+
if (isMissingRequiredOrMinValues ||
|
|
137
|
+
shouldBeArrayButIsnt ||
|
|
138
|
+
hasTooManyValues) {
|
|
135
139
|
const missingRequiredError = `${field.label ? `'${field.label}'` : 'This'} is required!`;
|
|
136
140
|
const missingMinValuesError = `${field.label ? `'${field.label}'` : 'You'} must ${field.label ? 'have' : 'select'} at least ${field.minArrayLength} value${field.minArrayLength === 1 ? '' : 's'}. I found ${valueAsArray.length}!`;
|
|
137
141
|
const mustBeArrayError = `${field.label ? `'${field.label}'` : 'This'} must be an array!`;
|
|
@@ -93,9 +93,13 @@ class StaticSchemaEntityImpl extends AbstractEntity {
|
|
|
93
93
|
const isMissingRequiredOrMinValues = field.isRequired &&
|
|
94
94
|
field.isArray &&
|
|
95
95
|
(!value || valueAsArray.length < ((_a = field.minArrayLength) !== null && _a !== void 0 ? _a : 1));
|
|
96
|
+
const hasTooManyValues = !!field.maxArrayLength &&
|
|
97
|
+
valueAsArray.length > field.maxArrayLength;
|
|
96
98
|
const isMissing = !value;
|
|
97
99
|
const shouldBeArrayButIsnt = !wasArray && !isMissing && field.isArray;
|
|
98
|
-
if (isMissingRequiredOrMinValues ||
|
|
100
|
+
if (isMissingRequiredOrMinValues ||
|
|
101
|
+
shouldBeArrayButIsnt ||
|
|
102
|
+
hasTooManyValues) {
|
|
99
103
|
const missingRequiredError = `${field.label ? `'${field.label}'` : 'This'} is required!`;
|
|
100
104
|
const missingMinValuesError = `${field.label ? `'${field.label}'` : 'You'} must ${field.label ? 'have' : 'select'} at least ${field.minArrayLength} value${field.minArrayLength === 1 ? '' : 's'}. I found ${valueAsArray.length}!`;
|
|
101
105
|
const mustBeArrayError = `${field.label ? `'${field.label}'` : 'This'} must be an array!`;
|
|
@@ -24,6 +24,7 @@ export default abstract class AbstractField<F extends FieldDefinitions> implemen
|
|
|
24
24
|
get label(): string | undefined;
|
|
25
25
|
get hint(): string | undefined;
|
|
26
26
|
get minArrayLength(): number;
|
|
27
|
+
get maxArrayLength(): number | undefined;
|
|
27
28
|
validate(value: any, _?: ValidateOptions<F>): FieldError[];
|
|
28
29
|
/** Transform any value to the value type of this field */
|
|
29
30
|
toValueType(value: any, _: any): any;
|
|
@@ -52,6 +52,9 @@ public static generateTemplateDetails(
|
|
|
52
52
|
var _b;
|
|
53
53
|
return (_b = this.definition.minArrayLength) !== null && _b !== void 0 ? _b : 1;
|
|
54
54
|
}
|
|
55
|
+
get maxArrayLength() {
|
|
56
|
+
return this.definition.maxArrayLength;
|
|
57
|
+
}
|
|
55
58
|
validate(value, _) {
|
|
56
59
|
const errors = [];
|
|
57
60
|
if (isUndefinedOrNull(value) && this.isRequired) {
|
|
@@ -34,6 +34,7 @@ export type FieldDefinition<Value = any, DefaultValue = Value, ArrayValue = Valu
|
|
|
34
34
|
hint?: string;
|
|
35
35
|
isRequired?: boolean;
|
|
36
36
|
minArrayLength?: number;
|
|
37
|
+
maxArrayLength?: number;
|
|
37
38
|
} & ({
|
|
38
39
|
isArray: true;
|
|
39
40
|
defaultValue?: DefaultArrayValue | null;
|
|
@@ -52,6 +53,7 @@ export interface Field<F extends FieldDefinitions> {
|
|
|
52
53
|
readonly isPrivate: F['isPrivate'];
|
|
53
54
|
readonly isArray: F['isArray'];
|
|
54
55
|
readonly minArrayLength: F['minArrayLength'];
|
|
56
|
+
readonly maxArrayLength: F['maxArrayLength'];
|
|
55
57
|
readonly label: F['label'];
|
|
56
58
|
readonly hint: F['hint'];
|
|
57
59
|
name: string;
|
|
@@ -24,6 +24,7 @@ export default abstract class AbstractField<F extends FieldDefinitions> implemen
|
|
|
24
24
|
get label(): string | undefined;
|
|
25
25
|
get hint(): string | undefined;
|
|
26
26
|
get minArrayLength(): number;
|
|
27
|
+
get maxArrayLength(): number | undefined;
|
|
27
28
|
validate(value: any, _?: ValidateOptions<F>): FieldError[];
|
|
28
29
|
/** Transform any value to the value type of this field */
|
|
29
30
|
toValueType(value: any, _: any): any;
|
|
@@ -56,6 +56,9 @@ public static generateTemplateDetails(
|
|
|
56
56
|
get minArrayLength() {
|
|
57
57
|
return this.definition.minArrayLength ?? 1;
|
|
58
58
|
}
|
|
59
|
+
get maxArrayLength() {
|
|
60
|
+
return this.definition.maxArrayLength;
|
|
61
|
+
}
|
|
59
62
|
validate(value, _) {
|
|
60
63
|
const errors = [];
|
|
61
64
|
if ((0, isUndefinedOrNull_1.default)(value) && this.isRequired) {
|
|
@@ -34,6 +34,7 @@ export type FieldDefinition<Value = any, DefaultValue = Value, ArrayValue = Valu
|
|
|
34
34
|
hint?: string;
|
|
35
35
|
isRequired?: boolean;
|
|
36
36
|
minArrayLength?: number;
|
|
37
|
+
maxArrayLength?: number;
|
|
37
38
|
} & ({
|
|
38
39
|
isArray: true;
|
|
39
40
|
defaultValue?: DefaultArrayValue | null;
|
|
@@ -52,6 +53,7 @@ export interface Field<F extends FieldDefinitions> {
|
|
|
52
53
|
readonly isPrivate: F['isPrivate'];
|
|
53
54
|
readonly isArray: F['isArray'];
|
|
54
55
|
readonly minArrayLength: F['minArrayLength'];
|
|
56
|
+
readonly maxArrayLength: F['maxArrayLength'];
|
|
55
57
|
readonly label: F['label'];
|
|
56
58
|
readonly hint: F['hint'];
|
|
57
59
|
name: string;
|