Skip to content

Commit 40dc734

Browse files
committed
Add Standard Schema validator integration
Introduce support for Standard Schema v1 validators across the project. Adds a new standard-schema module that detects ~standard validators, converts optional JSON Schema output into Async DB field metadata, merges overlays, and produces diagnostics. Runtime validators now support async validators via validateAsync/assertAsync; sync helpers throw DB_SCHEMA_ASYNC_VALIDATOR_REQUIRED when an async validator is encountered. Builder and schema generation were updated to accept validator-first shorthand and optionally emit validator-first .schema.mjs output (controlled by schema.standardSchema config). CLI bundling/unbundling, root bundle generation, and diagnostics were adjusted to import/reference executable validators and preserve behavior. Documentation and a runnable examples/standard-schema were added, plus corresponding type updates and tests.
1 parent 5d3f33a commit 40dc734

25 files changed

Lines changed: 1805 additions & 57 deletions

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Other useful paths:
4848
- [`examples/computed-fields`](./examples/computed-fields): computed field patterns across several schema-backed models.
4949
- [`examples/rest-client`](./examples/rest-client): calling @async/db from app or test code.
5050
- [`examples/schema-manifest`](./examples/schema-manifest): schema metadata for admin/CMS UI.
51+
- [`examples/standard-schema`](./examples/standard-schema): Standard Schema validators with Async DB metadata overlays.
5152
- [`examples/hono-auth`](./examples/hono-auth): optional Hono auth and write hooks.
5253

5354
See [Which Example Should I Start With?](#which-example-should-i-start-with) for the full examples map.
@@ -200,6 +201,22 @@ In mixed mode, schema files define the contract and data files provide seed reco
200201

201202
Schema defaults fill omitted fields on create and safe additive runtime hydration. Updates, patches, and document puts preserve omitted fields; include a field in the write body when you want to change it.
202203

204+
Executable `.schema.mjs` files can also accept Standard Schema-compatible validators:
205+
206+
```js
207+
import { collection, field } from '@async/db/schema';
208+
209+
export default collection({
210+
validator: UserSchema,
211+
fields: {
212+
email: field.string({ required: true, unique: true }),
213+
displayName: field.computed(field.string(), ({ record }) => record.email),
214+
},
215+
});
216+
```
217+
218+
The validator owns runtime parsing through `~standard.validate`; Async DB overlays keep generated metadata, relations, defaults, and computed resolvers. Async validators run in package, REST, and GraphQL writes. Sync schema helpers throw `DB_SCHEMA_ASYNC_VALIDATOR_REQUIRED` when the validator returns a Promise; use `validateAsync()` or `assertAsync()` for that path.
219+
203220
See [docs/concepts.md](./docs/concepts.md) and [docs/fixtures-and-schemas.md](./docs/fixtures-and-schemas.md).
204221

205222
## Validate Or Resolve From Schema
@@ -393,6 +410,7 @@ The examples are a learning path. Run any example with `node ./src/cli.js sync -
393410
| CSV as the source of truth | [`examples/csv`](./examples/csv) | CSV inference, source hashes, mirror refreshes |
394411
| Admin/CMS-style field metadata | [`examples/schema-manifest`](./examples/schema-manifest) | `outputs.schemaManifest` and manifest customization |
395412
| Schema JSON to simple CMS UI templates | [`examples/schema-ui`](./examples/schema-ui) | `serve.mjs` SSR view/editor HTML from manifest + mirror (`node ./examples/schema-ui/serve.mjs`); `/templates` route keeps static placeholders |
413+
| Standard Schema validators | [`examples/standard-schema`](./examples/standard-schema) | Dependency-free Standard Schema validation, `field.meta(...)` overlays, async write validation, computed fields, and conservative type fallback |
396414
| Diagnostics for schema/data drift | [`examples/diagnostics`](./examples/diagnostics) | Warnings surfaced without breaking unrelated resources |
397415
| Several advanced features together | [`examples/advanced`](./examples/advanced) | `.schema.mjs`, mixed mode, defaults, nested objects |
398416
| Hono auth and write hooks | [`examples/hono-auth`](./examples/hono-auth) | Optional Hono integration with auth lifecycle hooks |

docs/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ This folder is the durable markdown manual for @async/db. The root [README](../R
1010

1111
## Build Local Data
1212

13-
- [Fixtures And Schemas](./fixtures-and-schemas.md): JSON, JSONC, CSV, schema files, `.schema.mjs`, computed fields, source readers, nested folders, inference, and validation.
13+
- [Fixtures And Schemas](./fixtures-and-schemas.md): JSON, JSONC, CSV, schema files, `.schema.mjs`, Standard Schema validators, computed fields, source readers, nested folders, inference, and validation.
1414
- [Generated Files](./generated-files.md): `.db/`, state, generated TypeScript, committed generated outputs, schema manifests, and cleanup rules.
1515
- [Configuration](./configuration.md): `db.config.mjs`, fixture folders, resource naming, strictness, registered operations, mock delay/errors, server options, and forks.
1616
- [Schema UI example](../examples/schema-ui/README.md): manifest-driven CMS HTML with **`serve.mjs`** SSR from live mirror rows (`node ./examples/schema-ui/serve.mjs`).

docs/configuration.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ See [db.config.example.mjs](../db.config.example.mjs) for a commented config wit
3838
| App-facing data route base | `/db` | `server.dataPath` |
3939
| Route exposure policy | Open | `server.expose` |
4040
| Unknown fields | Warn | `schema.unknownFields` |
41+
| Standard Schema-first output | Off | `schema.standardSchema` |
4142
| Schema defaults | Create and safe hydration | `defaults` |
4243
| Schema-only mock records | Off | `seed.generateFromSchema` |
4344
| Local latency | `30-100ms` | `mock.delay` |
@@ -81,6 +82,7 @@ export default defineConfig({
8182
},
8283

8384
schema: {
85+
standardSchema: false,
8486
unknownFields: 'warn',
8587
},
8688

@@ -239,6 +241,28 @@ export default defineConfig({
239241

240242
Keep the default `warn` while fixture shape is still changing.
241243

244+
## Standard Schema Output
245+
246+
@async/db detects Standard Schema validators by shape without installing a
247+
validator dependency. Set `schema.standardSchema: true` when generated
248+
`.schema.mjs` files should prefer the validator-first authoring form for
249+
resources that have a Standard Schema validator:
250+
251+
```js
252+
import { defineConfig } from '@async/db/config';
253+
254+
export default defineConfig({
255+
schema: {
256+
standardSchema: true,
257+
},
258+
});
259+
```
260+
261+
With that option, aggregate bundle/unbundle output can emit
262+
`collection(UserSchema, { fields })` or `document(SettingsSchema, { fields })`
263+
for Standard Schema-backed resources. Resources without a validator keep the
264+
normal Async DB-first shape.
265+
242266
## Schema Defaults
243267

244268
Schema defaults apply when creating collection records through the package API, REST, GraphQL, SQLite adapter, and generated Hono SQLite starter. Updates, patches, and document puts preserve omitted fields instead of backfilling defaults; include a field in the write body when you want to change it.

docs/fixtures-and-schemas.md

Lines changed: 116 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,112 @@ export default collection({
130130

131131
`.schema.mjs` files execute as trusted local project JavaScript.
132132

133+
## Standard Schema Validators
134+
135+
`.schema.mjs` files can also use any object that implements the Standard Schema
136+
v1 contract. @async/db imports your trusted schema module, recognizes
137+
`value['~standard'].version === 1`, and calls
138+
`value['~standard'].validate(...)` during schema helpers and runtime writes.
139+
The core package does not bundle a validator-library dependency. That means
140+
Zod, Valibot, ArkType, or a local validator can own parsing and validation
141+
while Async DB still applies its own lightweight metadata checks for defaults,
142+
read-only/computed fields, uniqueness, relations, generated metadata, REST, and
143+
GraphQL.
144+
145+
```js
146+
import { collection, field } from '@async/db/schema';
147+
148+
const UserSchema = {
149+
'~standard': {
150+
version: 1,
151+
vendor: 'my-validator',
152+
async validate(value) {
153+
if (!value || typeof value !== 'object' || typeof value.email !== 'string') {
154+
return { issues: [{ message: 'Email is required', path: ['email'] }] };
155+
}
156+
return {
157+
value: {
158+
...value,
159+
email: value.email.trim().toLowerCase(),
160+
},
161+
};
162+
},
163+
jsonSchema: {
164+
output() {
165+
return {
166+
type: 'object',
167+
required: ['email'],
168+
properties: {
169+
id: { type: 'string' },
170+
email: { type: 'string' },
171+
},
172+
};
173+
},
174+
},
175+
},
176+
};
177+
178+
export default collection({
179+
idField: 'id',
180+
validator: UserSchema,
181+
fields: {
182+
email: field.string({
183+
required: true,
184+
unique: true,
185+
description: 'Normalized login email.',
186+
}),
187+
displayName: field.computed(field.string(), ({ record }) => record.email),
188+
},
189+
seed: [],
190+
});
191+
```
192+
193+
That object-first form keeps Async DB's simplified schema as the main shape and
194+
mixes Standard Schema in as the parser/validator through `validator`. The equivalent
195+
validator-first shorthand is useful when the external validator owns the field
196+
shape:
197+
198+
```js
199+
export default collection(UserSchema, {
200+
fields: {
201+
email: field.meta({ unique: true }),
202+
displayName: field.computed(field.string(), ({ record }) => record.email),
203+
},
204+
});
205+
```
206+
207+
Set `schema.standardSchema: true` in `db.config.mjs` when generated
208+
`.schema.mjs` files should prefer that validator-first form for resources that
209+
have a Standard Schema validator. Detection still works without the config flag;
210+
the flag only changes generated authoring output.
211+
212+
If the validator exposes a Standard JSON Schema converter, @async/db uses that
213+
for generated field metadata and TypeScript output. `field.meta(...)` overlays
214+
Async DB metadata such as descriptions, defaults, uniqueness, relations,
215+
constraints, and manifest hints. `field.computed(...)` remains the resolver
216+
entrypoint.
217+
218+
Package API, REST, and GraphQL writes await async Standard Schema validators and
219+
store the returned `value`. Synchronous helpers such as
220+
`schema.validator('users').assert(...)` work for sync validators; if a validator
221+
returns a Promise, use `validateAsync(...)` or `assertAsync(...)`. The sync path
222+
throws `DB_SCHEMA_ASYNC_VALIDATOR_REQUIRED` with that hint.
223+
224+
Standard Schema issues become `STANDARD_SCHEMA_VALIDATION_FAILED` diagnostics
225+
with a normalized field path, vendor, issue path, message, and recovery hint.
226+
Validator and resolver functions stay in trusted local code and are never
227+
serialized into generated schema, manifests, viewer metadata, or TypeScript
228+
output.
229+
230+
When a Standard Schema validator has no JSON Schema converter and no
231+
`field.meta(...)` overlays, generated TypeScript uses a conservative
232+
`[key: string]: unknown` fallback and the project diagnostics include
233+
`STANDARD_SCHEMA_FIELDS_UNKNOWN`. Add overlays or a converter when generated
234+
metadata needs to be richer.
235+
236+
See [examples/standard-schema](../examples/standard-schema) for a runnable
237+
dependency-free example.
238+
133239
## Root Schema Registry
134240

135241
Use `db.schema.mjs` at the project root when you want one canonical schema registry:
@@ -287,6 +393,9 @@ npm run db -- schema bundle users --out artifacts/users.bundle.schema.json
287393
npm run db -- schema unbundle users
288394
```
289395

396+
These single-resource JSON artifacts serialize Async DB metadata and seed data;
397+
they do not serialize executable validator or resolver functions.
398+
290399
If you omit the resource in an interactive terminal, the CLI prompts for either
291400
`All schemas` or a specific resource. Use `--all` in scripts to skip the prompt.
292401

@@ -307,11 +416,13 @@ example, `source: files('./**/*.mdx', { read: 'frontmatter' })` inside
307416
`source: files('./db/blog/**/*.mdx', { read: 'frontmatter' })` in
308417
`db.schema.mjs`, so the root registry can load the same content files.
309418

310-
When aggregate bundling sees computed resolvers from existing `.schema.mjs`
311-
files, the generated root schema imports the original module and emits inline
312-
named wrapper functions to preserve behavior. Schema, manifest, type, doctor,
313-
bundle, unbundle, and generated starter commands import trusted schema modules
314-
for metadata but do not call computed resolvers.
419+
When aggregate bundling sees computed resolvers or Standard Schema validators
420+
from existing `.schema.mjs` files, the generated root schema imports the
421+
original module, references its validator, and emits inline named resolver
422+
wrappers to preserve behavior. Aggregate unbundle writes `.schema.mjs` files for
423+
resources with executable validators or resolvers. Schema, manifest, type,
424+
doctor, bundle, unbundle, and generated starter commands import trusted schema
425+
modules for metadata but do not call computed resolvers.
315426

316427
## Inference
317428

examples/standard-schema/README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Standard Schema Example
2+
3+
This example shows the dependency-free integration path for validators that implement the Standard Schema v1 contract. The validator object lives in trusted project code; @async/db imports the schema module, recognizes `~standard.validate`, and calls it during schema helpers and runtime writes without bundling a validator-library dependency.
4+
5+
Run it:
6+
7+
```bash
8+
node ./src/cli.js sync --cwd ./examples/standard-schema
9+
node ./src/cli.js serve --cwd ./examples/standard-schema
10+
```
11+
12+
`db/users.schema.mjs` uses a small local Standard Schema-compatible validator. It lowercases email addresses during writes, exposes a Standard JSON Schema converter for field inference, and then layers Async DB metadata on top.
13+
14+
You can keep Async DB's object-first schema shape and mix the validator in:
15+
16+
```js
17+
export default collection({
18+
validator: UserSchema,
19+
fields: {
20+
email: field.string({ required: true, unique: true }),
21+
displayName: field.computed(field.string(), ({ record }) => record.email),
22+
},
23+
});
24+
```
25+
26+
The validator-first shorthand is also supported when the external schema owns the field shape:
27+
28+
```js
29+
export default collection(UserSchema, {
30+
idField: 'id',
31+
fields: {
32+
email: field.meta({ unique: true }),
33+
displayName: field.computed(field.string(), {
34+
resolveMany({ records }) {
35+
return new Map(records.map((record) => [
36+
record.id,
37+
`${record.firstName} ${record.lastName}`,
38+
]));
39+
},
40+
}),
41+
},
42+
});
43+
```
44+
45+
`field.meta(...)` is for Async DB metadata such as `unique`, `description`, defaults, relations, and manifest hints. `field.computed(...)` remains the resolver entrypoint, and resolver functions are not written to generated schema or manifest output.
46+
47+
The validator can be async. Package API, REST, and GraphQL writes await it and store the returned `value`.
48+
49+
```bash
50+
node ./src/cli.js create users '{"id":"u_2","email":" GRACE@EXAMPLE.COM ","firstName":"Grace","lastName":"Hopper"}' --cwd ./examples/standard-schema
51+
```
52+
53+
The stored email becomes `grace@example.com`.
54+
55+
`db/settings.schema.mjs` intentionally uses an opaque Standard Schema validator with no JSON Schema converter and no field overlay. In that case generated TypeScript falls back to a conservative index signature, and diagnostics ask you to add `field.meta(...)` overlays or provide a JSON Schema converter when you want richer generated metadata.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { document } from '@async/db/schema';
2+
3+
const SettingsSchema = {
4+
'~standard': {
5+
version: 1,
6+
vendor: 'opaque-standard-schema',
7+
validate(value) {
8+
return value && typeof value === 'object' && !Array.isArray(value)
9+
? { value }
10+
: { issues: [{ message: 'Expected settings object' }] };
11+
},
12+
},
13+
};
14+
15+
export default document(SettingsSchema, {
16+
seed: {
17+
theme: 'light',
18+
flags: {
19+
preview: true,
20+
},
21+
},
22+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { collection, field } from '@async/db/schema';
2+
3+
const UserSchema = {
4+
'~standard': {
5+
version: 1,
6+
vendor: 'example-standard-schema',
7+
async validate(value) {
8+
await Promise.resolve();
9+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
10+
return { issues: [{ message: 'Expected an object' }] };
11+
}
12+
if (typeof value.email !== 'string' || !value.email.includes('@')) {
13+
return {
14+
issues: [
15+
{
16+
message: 'Email must include @',
17+
path: ['email'],
18+
},
19+
],
20+
};
21+
}
22+
return {
23+
value: {
24+
...value,
25+
email: value.email.trim().toLowerCase(),
26+
},
27+
};
28+
},
29+
jsonSchema: {
30+
output() {
31+
return {
32+
type: 'object',
33+
required: ['email', 'firstName', 'lastName'],
34+
properties: {
35+
id: { type: 'string' },
36+
email: { type: 'string', description: 'Email address used for sign-in.' },
37+
firstName: { type: 'string' },
38+
lastName: { type: 'string' },
39+
},
40+
};
41+
},
42+
},
43+
},
44+
};
45+
46+
export default collection({
47+
idField: 'id',
48+
validator: UserSchema,
49+
fields: {
50+
email: field.string({
51+
required: true,
52+
unique: true,
53+
description: 'Normalized login email.',
54+
}),
55+
displayName: field.computed(field.string(), {
56+
resolveMany({ records }) {
57+
return new Map(records.map((record) => [
58+
record.id,
59+
`${record.firstName} ${record.lastName}`,
60+
]));
61+
},
62+
}),
63+
},
64+
seed: [
65+
{
66+
id: 'u_1',
67+
email: ' ADA@EXAMPLE.COM ',
68+
firstName: 'Ada',
69+
lastName: 'Lovelace',
70+
},
71+
],
72+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"title": "Standard Schema",
3+
"description": "Use any Standard Schema-compatible validator as the resource contract, then layer Async DB metadata and computed fields on top.",
4+
"tags": ["schema", "validation", "computed"]
5+
}

0 commit comments

Comments
 (0)