@nekostack/schema 1.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/CHANGELOG.md +422 -0
- package/LICENSE +202 -0
- package/README.md +656 -0
- package/dist/src/builders/array.d.ts +12 -0
- package/dist/src/builders/array.d.ts.map +1 -0
- package/dist/src/builders/array.js +29 -0
- package/dist/src/builders/array.js.map +1 -0
- package/dist/src/builders/object.d.ts +62 -0
- package/dist/src/builders/object.d.ts.map +1 -0
- package/dist/src/builders/object.js +263 -0
- package/dist/src/builders/object.js.map +1 -0
- package/dist/src/builders/primitives.d.ts +37 -0
- package/dist/src/builders/primitives.d.ts.map +1 -0
- package/dist/src/builders/primitives.js +125 -0
- package/dist/src/builders/primitives.js.map +1 -0
- package/dist/src/builders/s.d.ts +27 -0
- package/dist/src/builders/s.d.ts.map +1 -0
- package/dist/src/builders/s.js +39 -0
- package/dist/src/builders/s.js.map +1 -0
- package/dist/src/builders/schema.d.ts +70 -0
- package/dist/src/builders/schema.d.ts.map +1 -0
- package/dist/src/builders/schema.js +98 -0
- package/dist/src/builders/schema.js.map +1 -0
- package/dist/src/cli-integration.d.ts +43 -0
- package/dist/src/cli-integration.d.ts.map +1 -0
- package/dist/src/cli-integration.js +48 -0
- package/dist/src/cli-integration.js.map +1 -0
- package/dist/src/errors/issue.d.ts +34 -0
- package/dist/src/errors/issue.d.ts.map +1 -0
- package/dist/src/errors/issue.js +89 -0
- package/dist/src/errors/issue.js.map +1 -0
- package/dist/src/generators/errors.d.ts +31 -0
- package/dist/src/generators/errors.d.ts.map +1 -0
- package/dist/src/generators/errors.js +34 -0
- package/dist/src/generators/errors.js.map +1 -0
- package/dist/src/generators/header.d.ts +42 -0
- package/dist/src/generators/header.d.ts.map +1 -0
- package/dist/src/generators/header.js +43 -0
- package/dist/src/generators/header.js.map +1 -0
- package/dist/src/generators/json-schema-meta.d.ts +36 -0
- package/dist/src/generators/json-schema-meta.d.ts.map +1 -0
- package/dist/src/generators/json-schema-meta.js +35 -0
- package/dist/src/generators/json-schema-meta.js.map +1 -0
- package/dist/src/generators/json-schema.d.ts +26 -0
- package/dist/src/generators/json-schema.d.ts.map +1 -0
- package/dist/src/generators/json-schema.js +88 -0
- package/dist/src/generators/json-schema.js.map +1 -0
- package/dist/src/generators/openapi.d.ts +33 -0
- package/dist/src/generators/openapi.d.ts.map +1 -0
- package/dist/src/generators/openapi.js +61 -0
- package/dist/src/generators/openapi.js.map +1 -0
- package/dist/src/generators/schema-fragment.d.ts +55 -0
- package/dist/src/generators/schema-fragment.d.ts.map +1 -0
- package/dist/src/generators/schema-fragment.js +253 -0
- package/dist/src/generators/schema-fragment.js.map +1 -0
- package/dist/src/generators/ts.d.ts +19 -0
- package/dist/src/generators/ts.d.ts.map +1 -0
- package/dist/src/generators/ts.js +252 -0
- package/dist/src/generators/ts.js.map +1 -0
- package/dist/src/generators/types.d.ts +96 -0
- package/dist/src/generators/types.d.ts.map +1 -0
- package/dist/src/generators/types.js +10 -0
- package/dist/src/generators/types.js.map +1 -0
- package/dist/src/generators/version.d.ts +11 -0
- package/dist/src/generators/version.d.ts.map +1 -0
- package/dist/src/generators/version.js +11 -0
- package/dist/src/generators/version.js.map +1 -0
- package/dist/src/generators/zod-mapping.d.ts +90 -0
- package/dist/src/generators/zod-mapping.d.ts.map +1 -0
- package/dist/src/generators/zod-mapping.js +174 -0
- package/dist/src/generators/zod-mapping.js.map +1 -0
- package/dist/src/generators/zod.d.ts +17 -0
- package/dist/src/generators/zod.d.ts.map +1 -0
- package/dist/src/generators/zod.js +118 -0
- package/dist/src/generators/zod.js.map +1 -0
- package/dist/src/index.d.ts +21 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +43 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/ir/hash.d.ts +19 -0
- package/dist/src/ir/hash.d.ts.map +1 -0
- package/dist/src/ir/hash.js +22 -0
- package/dist/src/ir/hash.js.map +1 -0
- package/dist/src/ir/nodes.d.ts +121 -0
- package/dist/src/ir/nodes.d.ts.map +1 -0
- package/dist/src/ir/nodes.js +14 -0
- package/dist/src/ir/nodes.js.map +1 -0
- package/dist/src/ir/serialize.d.ts +8 -0
- package/dist/src/ir/serialize.d.ts.map +1 -0
- package/dist/src/ir/serialize.js +23 -0
- package/dist/src/ir/serialize.js.map +1 -0
- package/dist/src/migrations/build-migration-registry.d.ts +46 -0
- package/dist/src/migrations/build-migration-registry.d.ts.map +1 -0
- package/dist/src/migrations/build-migration-registry.js +134 -0
- package/dist/src/migrations/build-migration-registry.js.map +1 -0
- package/dist/src/migrations/handlers/list.d.ts +35 -0
- package/dist/src/migrations/handlers/list.d.ts.map +1 -0
- package/dist/src/migrations/handlers/list.js +55 -0
- package/dist/src/migrations/handlers/list.js.map +1 -0
- package/dist/src/migrations/handlers/plan.d.ts +26 -0
- package/dist/src/migrations/handlers/plan.d.ts.map +1 -0
- package/dist/src/migrations/handlers/plan.js +28 -0
- package/dist/src/migrations/handlers/plan.js.map +1 -0
- package/dist/src/migrations/handlers/stub.d.ts +22 -0
- package/dist/src/migrations/handlers/stub.d.ts.map +1 -0
- package/dist/src/migrations/handlers/stub.js +24 -0
- package/dist/src/migrations/handlers/stub.js.map +1 -0
- package/dist/src/migrations/handlers/verify.d.ts +25 -0
- package/dist/src/migrations/handlers/verify.d.ts.map +1 -0
- package/dist/src/migrations/handlers/verify.js +27 -0
- package/dist/src/migrations/handlers/verify.js.map +1 -0
- package/dist/src/migrations/parse-provenance.d.ts +78 -0
- package/dist/src/migrations/parse-provenance.d.ts.map +1 -0
- package/dist/src/migrations/parse-provenance.js +157 -0
- package/dist/src/migrations/parse-provenance.js.map +1 -0
- package/dist/src/migrations/plan-migration.d.ts +50 -0
- package/dist/src/migrations/plan-migration.d.ts.map +1 -0
- package/dist/src/migrations/plan-migration.js +256 -0
- package/dist/src/migrations/plan-migration.js.map +1 -0
- package/dist/src/migrations/stub.d.ts +55 -0
- package/dist/src/migrations/stub.d.ts.map +1 -0
- package/dist/src/migrations/stub.js +201 -0
- package/dist/src/migrations/stub.js.map +1 -0
- package/dist/src/migrations/types.d.ts +297 -0
- package/dist/src/migrations/types.d.ts.map +1 -0
- package/dist/src/migrations/types.js +28 -0
- package/dist/src/migrations/types.js.map +1 -0
- package/dist/src/migrations/verify-provenance.d.ts +46 -0
- package/dist/src/migrations/verify-provenance.d.ts.map +1 -0
- package/dist/src/migrations/verify-provenance.js +158 -0
- package/dist/src/migrations/verify-provenance.js.map +1 -0
- package/dist/src/registry/build-registry.d.ts +65 -0
- package/dist/src/registry/build-registry.d.ts.map +1 -0
- package/dist/src/registry/build-registry.js +172 -0
- package/dist/src/registry/build-registry.js.map +1 -0
- package/dist/src/registry/diff.d.ts +25 -0
- package/dist/src/registry/diff.d.ts.map +1 -0
- package/dist/src/registry/diff.js +497 -0
- package/dist/src/registry/diff.js.map +1 -0
- package/dist/src/registry/handlers/check.d.ts +57 -0
- package/dist/src/registry/handlers/check.d.ts.map +1 -0
- package/dist/src/registry/handlers/check.js +181 -0
- package/dist/src/registry/handlers/check.js.map +1 -0
- package/dist/src/registry/handlers/diff.d.ts +33 -0
- package/dist/src/registry/handlers/diff.d.ts.map +1 -0
- package/dist/src/registry/handlers/diff.js +61 -0
- package/dist/src/registry/handlers/diff.js.map +1 -0
- package/dist/src/registry/handlers/generate.d.ts +87 -0
- package/dist/src/registry/handlers/generate.d.ts.map +1 -0
- package/dist/src/registry/handlers/generate.js +223 -0
- package/dist/src/registry/handlers/generate.js.map +1 -0
- package/dist/src/registry/handlers/list.d.ts +36 -0
- package/dist/src/registry/handlers/list.d.ts.map +1 -0
- package/dist/src/registry/handlers/list.js +84 -0
- package/dist/src/registry/handlers/list.js.map +1 -0
- package/dist/src/registry/parse-provenance.d.ts +63 -0
- package/dist/src/registry/parse-provenance.d.ts.map +1 -0
- package/dist/src/registry/parse-provenance.js +182 -0
- package/dist/src/registry/parse-provenance.js.map +1 -0
- package/dist/src/registry/source-hash.d.ts +28 -0
- package/dist/src/registry/source-hash.d.ts.map +1 -0
- package/dist/src/registry/source-hash.js +32 -0
- package/dist/src/registry/source-hash.js.map +1 -0
- package/dist/src/registry/types.d.ts +185 -0
- package/dist/src/registry/types.d.ts.map +1 -0
- package/dist/src/registry/types.js +22 -0
- package/dist/src/registry/types.js.map +1 -0
- package/dist/src/runtime/compile.d.ts +38 -0
- package/dist/src/runtime/compile.d.ts.map +1 -0
- package/dist/src/runtime/compile.js +45 -0
- package/dist/src/runtime/compile.js.map +1 -0
- package/dist/src/runtime/errors.d.ts +25 -0
- package/dist/src/runtime/errors.d.ts.map +1 -0
- package/dist/src/runtime/errors.js +43 -0
- package/dist/src/runtime/errors.js.map +1 -0
- package/dist/src/runtime/normalize-issues.d.ts +65 -0
- package/dist/src/runtime/normalize-issues.d.ts.map +1 -0
- package/dist/src/runtime/normalize-issues.js +208 -0
- package/dist/src/runtime/normalize-issues.js.map +1 -0
- package/dist/src/runtime/parse.d.ts +62 -0
- package/dist/src/runtime/parse.d.ts.map +1 -0
- package/dist/src/runtime/parse.js +107 -0
- package/dist/src/runtime/parse.js.map +1 -0
- package/dist/src/runtime/strip-defaults.d.ts +51 -0
- package/dist/src/runtime/strip-defaults.d.ts.map +1 -0
- package/dist/src/runtime/strip-defaults.js +81 -0
- package/dist/src/runtime/strip-defaults.js.map +1 -0
- package/dist/src/runtime/zod-compile.d.ts +27 -0
- package/dist/src/runtime/zod-compile.d.ts.map +1 -0
- package/dist/src/runtime/zod-compile.js +92 -0
- package/dist/src/runtime/zod-compile.js.map +1 -0
- package/dist/src/types.d.ts +116 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/docs/ABSENCE_SEMANTICS.md +37 -0
- package/docs/BENCHMARKS.md +64 -0
- package/docs/COMPOSITION.md +206 -0
- package/docs/DIFF_CLASSIFICATION.md +137 -0
- package/docs/EXAMPLES.md +221 -0
- package/docs/HEADER_FORMAT.md +66 -0
- package/docs/INVARIANTS.md +58 -0
- package/docs/IR_CONTRACT.md +67 -0
- package/docs/ISSUE_CODES.md +99 -0
- package/docs/JSON_SCHEMA_MAPPING.md +149 -0
- package/docs/MIGRATIONS.md +406 -0
- package/docs/MIGRATION_GUIDE.md +150 -0
- package/docs/OPENAPI_MAPPING.md +66 -0
- package/docs/REGISTRY.md +336 -0
- package/docs/RUNTIME.md +279 -0
- package/docs/SCOPE.md +119 -0
- package/docs/USAGE.md +376 -0
- package/docs/ZOD_MODIFIER_ORDERING.md +77 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
# @nekostack/schema
|
|
2
|
+
|
|
3
|
+
> An IR-backed multi-output schema system. Define types once; emit Zod validators, JSON Schema, OpenAPI, and TypeScript from a canonical intermediate representation. The implementation risk is not the DSL — it's semantic consistency across outputs.
|
|
4
|
+
|
|
5
|
+
## Quick reference
|
|
6
|
+
|
|
7
|
+
| | |
|
|
8
|
+
|---|---|
|
|
9
|
+
| **Build tier** | Foundation primitive — build first |
|
|
10
|
+
| **Depends on** | (none — foundational). External: TypeScript (dev/build). Peer: Zod (for runtime validation; not required at IR level). |
|
|
11
|
+
| **Used by** | `api`, `cli`, `codex`, `auth`, `form`, `config`, `events`, `telemetry`, `validator`, `id`, `entitlements`, `lint` (rule authoring), and effectively everything else |
|
|
12
|
+
| **Status** | **v1.0 — released.** Canonical IR + Zod / JSON Schema / OpenAPI / TypeScript generators + runtime validation. 1,294 tests; public surface frozen. |
|
|
13
|
+
| **Released** | [`schema-v1.0.0`](./CHANGELOG.md) — API frozen. Reserved-but-unbuilt IR capacity (unions, lazy/recursive refs, transforms, dates) lands in post-1.0 minors. |
|
|
14
|
+
| **Sellable?** | Not as the package itself. It's the **technical substrate** for a future registry/governance product. Schema-as-a-service requires registry + diffing + history + governance + CI integration on top — see [Product potential](#product-potential). |
|
|
15
|
+
|
|
16
|
+
## Why this exists
|
|
17
|
+
|
|
18
|
+
Every non-trivial app defines the same data shape in multiple places: TypeScript `interface`, Zod schema, JSON Schema, OpenAPI spec, Prisma model, form schema. They drift. Always. A field gets added in one place and forgotten in three others, and the bugs that result are silent until someone hits the unhappy path.
|
|
19
|
+
|
|
20
|
+
`@nekostack/schema` solves this the same way every serious schema-first stack does: **define the shape once in a single DSL, normalize to an internal representation, and generate every downstream form from that representation.** The unique value isn't the DSL — Zod, TypeBox, Effect-Schema, and io-ts all have nice DSLs. The unique value is the **canonical IR + semantic-loss discipline**: every generator consumes the IR (not builder internals), and where outputs cannot faithfully represent the IR (e.g., a custom refinement in JSON Schema), the system explicitly marks the gap rather than silently lying.
|
|
21
|
+
|
|
22
|
+
Building this rather than adopting Zod is justified because:
|
|
23
|
+
|
|
24
|
+
1. **Zod is one output target, not the source.** A Zod-as-source architecture forces every non-Zod consumer to glue through adapters that drift.
|
|
25
|
+
2. **You learn schema-system internals end-to-end** — IR design, generator architecture, type-level TypeScript, JSON Schema semantics, OpenAPI 3.1 nuances, semantic-loss management.
|
|
26
|
+
3. **Outputs target NekoStack's specific consumers** — the Zod output knows about NekoStack's normalized `Issue` shape; the OpenAPI output knows how NekoStack APIs version themselves.
|
|
27
|
+
4. **No vendor coupling.** Zod 4 introduces a breaking change? The generator emits Zod 3 syntax until you choose to migrate.
|
|
28
|
+
|
|
29
|
+
## Scope
|
|
30
|
+
|
|
31
|
+
### In scope
|
|
32
|
+
- DSL: object, array, primitives, union, enum, literal, recursive (via `s.lazy()`), optional/nullable/nullish/default, transform.
|
|
33
|
+
- **Date typing** — explicit per use case: `s.isoDateTime()`, `s.isoDate()`, `s.epochMs()`, `s.dateObject()` (runtime-only).
|
|
34
|
+
- **Two refinement classes** — *portable constraints* (min/max/regex/format/etc.) and *runtime-only* refinements (custom predicates).
|
|
35
|
+
- Canonical IR: every builder produces a normalized `SchemaNode` tree; generators consume only IR.
|
|
36
|
+
- Generators: TypeScript types, Zod validators, JSON Schema (draft 2020-12), OpenAPI 3.1 component schemas.
|
|
37
|
+
- Composition: `extend`, `omit`, `pick`, `partial`, `required`, and conflict-safe `merge` (with explicit `override`).
|
|
38
|
+
- Schema identity: reverse-DNS IDs + versions for cross-package + recursive references.
|
|
39
|
+
- Validation runtime: structured `Issue[]` with stable, normalized codes.
|
|
40
|
+
- Strict-by-default object behavior; explicit `.stripUnknown()` / `.passthrough()` opt-ins.
|
|
41
|
+
- Generated artifact lifecycle: deterministic headers, source-hash tracking, freshness checks.
|
|
42
|
+
- CLI commands (via `@nekostack/cli`): `schema generate`, `schema check`, `schema diff`.
|
|
43
|
+
|
|
44
|
+
### Out of scope
|
|
45
|
+
- Database schema generation (DDL) — `@nekostack/migrate` territory.
|
|
46
|
+
- GraphQL SDL output — could be a future generator; not in 1.0.
|
|
47
|
+
- Runtime validation *library implementation* — we generate Zod schemas; we don't reimplement Zod's runtime.
|
|
48
|
+
- Form rendering — `@nekostack/form`'s job; it consumes our schemas.
|
|
49
|
+
- Schema *registry as a service* (hosted history, team permissions, governance) — future commercial layer above this package.
|
|
50
|
+
|
|
51
|
+
## Boundary
|
|
52
|
+
|
|
53
|
+
> See [`BOUNDARIES.md`](../../BOUNDARIES.md) §7 for the full capability map.
|
|
54
|
+
|
|
55
|
+
### Owns
|
|
56
|
+
- Canonical IR (`SchemaNode` AST)
|
|
57
|
+
- DSL builders that produce IR
|
|
58
|
+
- TS, Zod, JSON Schema, OpenAPI generators
|
|
59
|
+
- Composition operators with explicit conflict rules
|
|
60
|
+
- Schema identity + versioning metadata
|
|
61
|
+
- Runtime validation (executes IR-backed generated validators)
|
|
62
|
+
- Normalized `Issue` shape + stable codes
|
|
63
|
+
- Generated artifact policy (headers, hashes, freshness)
|
|
64
|
+
|
|
65
|
+
### Does NOT own
|
|
66
|
+
| Capability | Lives in |
|
|
67
|
+
|---|---|
|
|
68
|
+
| Form input validation UI + state | `form` (consumes our schemas) |
|
|
69
|
+
| API request/response boundary validation | `api` (consumes our schemas) |
|
|
70
|
+
| Cross-reference / continuity validation | `validator` |
|
|
71
|
+
| Database schema definition + DDL | `migrate` (works with us for versioning) |
|
|
72
|
+
| Branded ID types (UUID/ULID/branded primitives) | `id` (uses us as substrate) |
|
|
73
|
+
| ESLint rule authoring | `lint` |
|
|
74
|
+
| GraphQL SDL output | external (not in v1; could be future generator) |
|
|
75
|
+
| Runtime validation library *implementation* | external (Zod — we generate, we don't reimplement) |
|
|
76
|
+
| Schema **migration execution** at scale | future package or v0.8+ here, gated on production need |
|
|
77
|
+
| Global CLI runtime, plugin discovery, terminal UX, workspace command orchestration | `cli` |
|
|
78
|
+
|
|
79
|
+
### CLI ownership note
|
|
80
|
+
|
|
81
|
+
`@nekostack/schema` may expose schema-specific CLI command **handlers**, but it does not own the global CLI runtime. Global command routing, plugin discovery, terminal UX, and workspace command orchestration belong to `@nekostack/cli`.
|
|
82
|
+
|
|
83
|
+
Concretely: the `src/cli/` subdirectory in this package exports handler functions for `schema generate`, `schema check`, `schema diff`. These are registered with `@nekostack/cli` as plugin commands per its plugin contract. We do not own the `neko` binary, the argv parser, the interactive prompt UX, the help system, or any cross-package command orchestration — those are CLI-package concerns. This prevents future scope creep while keeping `schema generate / check / diff` available to users.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Implementation contracts
|
|
88
|
+
|
|
89
|
+
This section pins the load-bearing decisions that every implementer (including future-you) must respect. The DSL is the easy part; these are where schema systems usually rot.
|
|
90
|
+
|
|
91
|
+
### Canonical Intermediate Representation (IR)
|
|
92
|
+
|
|
93
|
+
Every schema builder produces a serializable, normalized `SchemaNode` AST. **Generators consume only the IR — never private builder internals.**
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
type SchemaNode =
|
|
97
|
+
| StringNode
|
|
98
|
+
| NumberNode
|
|
99
|
+
| BooleanNode
|
|
100
|
+
| DateNode // narrowed; see "Date types" below
|
|
101
|
+
| LiteralNode
|
|
102
|
+
| EnumNode
|
|
103
|
+
| ArrayNode
|
|
104
|
+
| ObjectNode
|
|
105
|
+
| UnionNode
|
|
106
|
+
| RefinementNode // marks portable vs runtime-only — explicit
|
|
107
|
+
| RecursiveRefNode // requires a stable schema id (see "Identity")
|
|
108
|
+
| TransformNode; // runtime-only; serialized as opaque metadata
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The hard rule, repeated for emphasis:
|
|
112
|
+
|
|
113
|
+
> **Builders create IR. Generators consume IR. Runtime validators execute the generated validator backed by IR.**
|
|
114
|
+
|
|
115
|
+
Without this, the DSL is the source of truth in name only — every generator starts interpreting builder objects differently, and the four outputs drift. The IR is the single contract that prevents drift.
|
|
116
|
+
|
|
117
|
+
### Date types
|
|
118
|
+
|
|
119
|
+
`s.date()` is rejected as ambiguous. Date handling is explicit per use case:
|
|
120
|
+
|
|
121
|
+
| DSL | Runtime input | Serialized form | JSON Schema | When to use |
|
|
122
|
+
|---|---|---|---|---|
|
|
123
|
+
| `s.isoDateTime()` | ISO 8601 string | string | `format: date-time` | **default for APIs / config / cross-system** |
|
|
124
|
+
| `s.isoDate()` | YYYY-MM-DD string | string | `format: date` | date-only fields |
|
|
125
|
+
| `s.epochMs()` | number | number | `integer, format: int64` | high-throughput logs, telemetry |
|
|
126
|
+
| `s.dateObject()` | `Date` object | `Date` object | runtime-only metadata | in-process only — never serialized |
|
|
127
|
+
|
|
128
|
+
`dateObject()` is the only one not portable to non-TS consumers; the IR marks it as runtime-only.
|
|
129
|
+
|
|
130
|
+
### Absence semantics
|
|
131
|
+
|
|
132
|
+
The most under-specified part of any schema system. NekoStack pins these explicitly:
|
|
133
|
+
|
|
134
|
+
| DSL call | TypeScript | Runtime accepts | JSON Schema `required`? | OpenAPI `nullable`? |
|
|
135
|
+
|---|---|---|---|---|
|
|
136
|
+
| `s.string()` | `field: string` | string only | yes | no |
|
|
137
|
+
| `s.string().optional()` | `field?: string` | missing or undefined | no | no |
|
|
138
|
+
| `s.string().nullable()` | `field: string \| null` | string or null; missing rejected | yes | yes |
|
|
139
|
+
| `s.string().nullish()` | `field?: string \| null` | missing, undefined, or null | no | yes |
|
|
140
|
+
| `s.string().default("x")` | input `field?: string`; output `field: string` | missing accepted; replaced | no (default emitted) | no |
|
|
141
|
+
|
|
142
|
+
`optional()` and `nullable()` are different. `null` is a value; missing is the absence of a value. Conflating them is the most common source of API drift.
|
|
143
|
+
|
|
144
|
+
### Refinement portability
|
|
145
|
+
|
|
146
|
+
A refinement is either **portable** (representable in every output format) or **runtime-only** (only representable in Zod / runtime validators). The DSL forces the distinction at definition time.
|
|
147
|
+
|
|
148
|
+
**Portable constraints** map to known output features:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
s.string().min(3) // → JSON Schema minLength
|
|
152
|
+
s.string().max(50) // → maxLength
|
|
153
|
+
s.string().regex(/^NEKO_/) // → pattern
|
|
154
|
+
s.string().email() // → format: email
|
|
155
|
+
s.number().int().min(0) // → integer minimum
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Runtime-only refinements** are custom predicates only Zod / our runtime can execute:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
s.string().refine(value => isValidTenantSlug(value), {
|
|
162
|
+
code: "invalid_tenant_slug",
|
|
163
|
+
description: "Must match tenant slug rules",
|
|
164
|
+
});
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
These cannot be faithfully represented in JSON Schema or OpenAPI. Generators emit metadata when semantic loss occurs:
|
|
168
|
+
|
|
169
|
+
```json
|
|
170
|
+
{
|
|
171
|
+
"type": "string",
|
|
172
|
+
"x-nekostack-runtime-refinement": true,
|
|
173
|
+
"x-nekostack-refinement-code": "invalid_tenant_slug"
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**Key property:** the JSON Schema / OpenAPI outputs are always **correct supersets** of the true validation rules. They accept everything the strict runtime would accept (and possibly more, because the runtime-only refinement is invisible to them). They never silently misrepresent stricter behavior.
|
|
178
|
+
|
|
179
|
+
### Unknown-key policy
|
|
180
|
+
|
|
181
|
+
By default, every object schema is **strict**: unknown keys cause validation to fail.
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
s.object({ email: s.string() })
|
|
185
|
+
// Input: { email: "x@example.com", admin: true }
|
|
186
|
+
// Result: ❌ Issue { code: "unknown_key", path: ["admin"] }
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Permissive behavior is opt-in:
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
s.object({ email: s.string() }).strict() // explicit reject (default)
|
|
193
|
+
s.object({ email: s.string() }).stripUnknown() // silently strip extras
|
|
194
|
+
s.object({ email: s.string() }).passthrough() // keep extras unchecked
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Strict-by-default is non-negotiable for auth, API, and config schemas. Permissive behavior must be a deliberate choice, not an accidental default.
|
|
198
|
+
|
|
199
|
+
### Schema identity ($id / $ref strategy)
|
|
200
|
+
|
|
201
|
+
Recursive and cross-package schemas require stable identifiers. NekoStack uses reverse-DNS-style IDs with versions:
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
const User = s.object({
|
|
205
|
+
id: s.string().uuid(),
|
|
206
|
+
manager: s.lazy(() => User).optional(),
|
|
207
|
+
})
|
|
208
|
+
.id("com.nekostack.auth.User")
|
|
209
|
+
.version("1.0.0")
|
|
210
|
+
.describe("Authenticated user");
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Rules:
|
|
214
|
+
|
|
215
|
+
- Every schema referenced by `s.lazy()` (i.e., recursive) **MUST** have an `.id()`. Unnamed recursive schemas are rejected at definition time.
|
|
216
|
+
- IDs are globally unique within a NekoStack workspace. Two packages cannot both define `com.nekostack.auth.User` at the same version.
|
|
217
|
+
- Generated JSON Schema uses `$id` + `$defs` based on the schema ID.
|
|
218
|
+
- Cross-package references compose: `@nekostack/auth` schemas can reference `com.nekostack.tenant.Tenant` without import gymnastics — the JSON Schema output emits a `$ref` URL the consumer resolves through the local registry.
|
|
219
|
+
- Schema versions participate in identity. `com.nekostack.auth.User@1.0.0` and `...@2.0.0` are distinct schemas; migrations bridge them (v0.8+).
|
|
220
|
+
|
|
221
|
+
### Composition conflict rules
|
|
222
|
+
|
|
223
|
+
`merge()` is the only composition operator that can produce conflicts. Default behavior is **fail loudly**:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
const A = s.object({ id: s.string() });
|
|
227
|
+
const B = s.object({ id: s.number() });
|
|
228
|
+
|
|
229
|
+
A.merge(B); // ❌ throws: field 'id' conflict
|
|
230
|
+
A.merge(B, { conflict: "right" }); // explicit: right side wins
|
|
231
|
+
A.merge(B, { conflict: "left" }); // explicit: left side wins
|
|
232
|
+
A.override({ id: s.number() }); // explicit named override
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Silent merge replacement is forbidden. Auth schemas, in particular, must not be partially clobbered by composition.
|
|
236
|
+
|
|
237
|
+
Other composition operators (`extend`, `pick`, `omit`, `partial`, `required`) cannot produce field-type conflicts and need no conflict policy.
|
|
238
|
+
|
|
239
|
+
### Transform semantics
|
|
240
|
+
|
|
241
|
+
Transforms create two distinct types — the *input* the validator accepts and the *output* it returns:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
const ParsedAge = s.string().transform(v => Number(v));
|
|
245
|
+
// ^^^^^^^^^^ ^^^^^^
|
|
246
|
+
// input: string output: number
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
NekoStack exposes both explicitly:
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
s.input<typeof ParsedAge> // string
|
|
253
|
+
s.output<typeof ParsedAge> // number
|
|
254
|
+
s.infer<typeof ParsedAge> // alias to output (Zod-compatible default)
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`s.infer` aliasing to **output** matches Zod ergonomics and what most consumers want ("the validated/parsed type"). For form bindings and API request shapes, `s.input` is the right type. The IR's `TransformNode` carries both annotations so generators can emit either side correctly.
|
|
258
|
+
|
|
259
|
+
JSON Schema and OpenAPI outputs describe the **input** type (the wire format). The transformation only happens at runtime, so non-runtime outputs cannot represent it — semantic-loss metadata flags this.
|
|
260
|
+
|
|
261
|
+
### Union policy
|
|
262
|
+
|
|
263
|
+
Two union forms with different semantics:
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
s.union([A, B, C]) // plain union
|
|
267
|
+
s.discriminatedUnion("kind", [A, B, C]) // discriminated by 'kind' field
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Rules:
|
|
271
|
+
|
|
272
|
+
- Plain unions are allowed but **discouraged for object variants**. Validation tries each branch in order and aggregates errors.
|
|
273
|
+
- Object unions SHOULD use `discriminatedUnion`. Generated OpenAPI emits `oneOf` with `discriminator` metadata, producing cleaner client codegen and clearer error reporting.
|
|
274
|
+
- **Issue reporting for unions:** the runtime returns issues from the *best-matching* branch (the branch that progressed furthest before failing). For `discriminatedUnion`, the discriminator-matched branch's issues are returned exclusively — no ambiguity about which variant the user intended.
|
|
275
|
+
|
|
276
|
+
The IR's `UnionNode` tracks whether the union is discriminated and, if so, the discriminator field. Generators consume this to emit correct OpenAPI / JSON Schema.
|
|
277
|
+
|
|
278
|
+
### Validate vs parse
|
|
279
|
+
|
|
280
|
+
The runtime exposes two distinct operations:
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
validate(schema, input) // read-only check; returns Result<input, Issue[]>; no mutation, no defaults, no transforms
|
|
284
|
+
parse(schema, input) // validates + applies defaults + runs transforms; returns Result<output, Issue[]>
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
- **`validate`** answers "does this input conform?" It does not mutate, default, or coerce. Returns the input unchanged on success, typed at the **input** shape.
|
|
288
|
+
- **`parse`** answers "what is the normalized output?" It applies defaults, runs transforms, returns the **output** shape. Most consumers want `parse`.
|
|
289
|
+
|
|
290
|
+
Use the right one:
|
|
291
|
+
- **Config loading at boot** — `parse` (apply defaults so the config object is fully populated).
|
|
292
|
+
- **API request bodies** — `parse` (canonical shape for downstream code).
|
|
293
|
+
- **Form initialization** — `parse` (form starts pre-populated with defaults).
|
|
294
|
+
- **Observability/audit inspection** — `validate` (don't mutate the value being recorded).
|
|
295
|
+
- **Permission/entitlement decision input checks** — `validate` (input is data; don't transform it).
|
|
296
|
+
|
|
297
|
+
Generators emit code that supports both. Consumers pick.
|
|
298
|
+
|
|
299
|
+
### Coercion policy
|
|
300
|
+
|
|
301
|
+
**No implicit coercion.** By default:
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
const Age = s.number();
|
|
305
|
+
parse(Age, "42"); // ❌ Issue { code: "invalid_type", expected: "number", received: "string" }
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Explicit coercion is opt-in via a separate constructor (not a chainable method on the base type):
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
s.number().coerceFromString(); // accepts "42" → 42
|
|
312
|
+
s.boolean().coerceFromString(); // accepts "true"/"false" → boolean (rejects "1"/"yes" — too forgiving)
|
|
313
|
+
s.isoDateTime().coerceFromString(); // accepts ISO 8601 strings → normalized
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Why no `s.coerce.number()` like Zod:
|
|
317
|
+
|
|
318
|
+
- Silent coercion is a security smell. `"true"`, `"1"`, `"yes"` all becoming `true` without explicit opt-in causes real bugs.
|
|
319
|
+
- Coerced types must be representable in non-runtime outputs as the coerced shape (not the original). JSON Schema describes the wire format expected; if the wire format is "string coercible to number," that's a different schema and we surface it explicitly.
|
|
320
|
+
- Coercion never leaks into JSON Schema / OpenAPI as native behavior. It's runtime-only with semantic-loss metadata when the output schema differs from the runtime acceptance.
|
|
321
|
+
|
|
322
|
+
### Error model
|
|
323
|
+
|
|
324
|
+
Validation errors are structured `Issue` records — never raw strings, never raw Zod errors:
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
type Issue = {
|
|
328
|
+
code: IssueCode;
|
|
329
|
+
path: Array<string | number>;
|
|
330
|
+
message: string;
|
|
331
|
+
expected?: unknown;
|
|
332
|
+
received?: unknown;
|
|
333
|
+
schemaId?: string;
|
|
334
|
+
schemaVersion?: string;
|
|
335
|
+
severity: "error" | "warning";
|
|
336
|
+
metadata?: Record<string, unknown>;
|
|
337
|
+
};
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Codes are stable, machine-readable, and NekoStack-normalized:
|
|
341
|
+
|
|
342
|
+
```
|
|
343
|
+
invalid_type
|
|
344
|
+
missing_required
|
|
345
|
+
unknown_key
|
|
346
|
+
too_small
|
|
347
|
+
too_big
|
|
348
|
+
invalid_enum
|
|
349
|
+
invalid_literal
|
|
350
|
+
invalid_union
|
|
351
|
+
custom_refinement_failed
|
|
352
|
+
schema_version_unsupported
|
|
353
|
+
recursive_reference_unresolved
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
A [companion spec doc](./docs/ISSUE_CODES.md) catalogs the full set with descriptions, example messages, and Zod-to-NekoStack mappings. The runtime validator normalizes raw Zod issues into this shape; consumers (`form` error display, `api` error responses, `admin` diagnostics) all read the same structure.
|
|
357
|
+
|
|
358
|
+
### Generated artifact policy
|
|
359
|
+
|
|
360
|
+
Every generated file includes a deterministic header:
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
/**
|
|
364
|
+
* @generated by @nekostack/schema
|
|
365
|
+
* source: user.schema.ts
|
|
366
|
+
* schemaId: com.nekostack.auth.User
|
|
367
|
+
* schemaVersion: 1.0.0
|
|
368
|
+
* sourceHash: sha256:7f3e2a9b...
|
|
369
|
+
* irHash: sha256:c4a2e810...
|
|
370
|
+
* generator: zod@0.4.1
|
|
371
|
+
* DO NOT EDIT MANUALLY.
|
|
372
|
+
*/
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
**Two hashes, distinguishing two different changes:**
|
|
376
|
+
|
|
377
|
+
- **`sourceHash`** changes when the source file changes — including whitespace, comments, reorderings, anything textual.
|
|
378
|
+
- **`irHash`** changes only when the normalized IR changes — i.e., the actual schema semantics.
|
|
379
|
+
|
|
380
|
+
This distinction matters for review hygiene. CI can categorize a PR:
|
|
381
|
+
|
|
382
|
+
- *Source changed, IR identical* — comment / whitespace / reorder only. Regeneration not required (header `sourceHash` is updated; output bytes don't change).
|
|
383
|
+
- *Source changed, IR changed* — real schema change. Regeneration required; reviewers must inspect generated diffs.
|
|
384
|
+
- *Generated output's `irHash` doesn't match current IR* — stale. CI fails.
|
|
385
|
+
|
|
386
|
+
Rules:
|
|
387
|
+
|
|
388
|
+
- **Committed to git.** Generated files are tracked, not gitignored. Makes review possible, prevents "works on my machine" drift.
|
|
389
|
+
- **Never manually edited.** CI verifies both `sourceHash` and `irHash` against current source. Manual edits would be silently overwritten by the next `neko schema generate`.
|
|
390
|
+
- **Deterministic.** Same IR + same generator version → byte-identical output.
|
|
391
|
+
- **Freshness check.** `neko schema check` rehashes sources, recomputes IR, verifies generated artifacts match on both `sourceHash` and `irHash`. CI fails on stale output.
|
|
392
|
+
- **Incremental.** Only schemas with changed `irHash` need regeneration (source-only changes update the header but produce identical bytes elsewhere — still rewritten so the header is fresh).
|
|
393
|
+
- **Outputs separated.** TypeScript `.d.ts`, Zod `.ts`, JSON Schema `.json`, OpenAPI `.openapi.json` are distinct files. Allows each output to be consumed in isolation.
|
|
394
|
+
- **Headers describe lineage.** Source path, schema ID, version, both hashes, generator version — all readable from the file itself.
|
|
395
|
+
|
|
396
|
+
A companion spec doc (deferred to v1.0) will detail header format, the exact hash algorithm (canonical JSON serialization → SHA-256), and the `neko schema check` exit-code contract.
|
|
397
|
+
|
|
398
|
+
### Dependency policy
|
|
399
|
+
|
|
400
|
+
The package is foundational within NekoStack — no NekoStack-package dependencies. External dependencies are classified:
|
|
401
|
+
|
|
402
|
+
- **Runtime (in the published package):** none in the core; the IR + builders + generators are pure TS. Specific generators may pull tiny utility libs (e.g., a JSON Schema validator dependency for self-conformance tests stays dev-only).
|
|
403
|
+
- **Peer:** Zod, when the consumer uses the generated Zod validator at runtime. Declared as peerDep so consumers control the Zod version.
|
|
404
|
+
- **Dev-only:** Zod (for self-tests), `ajv` (JSON Schema conformance), `@redocly/openapi-core` (OpenAPI fixture validation).
|
|
405
|
+
|
|
406
|
+
The package can *generate* Zod without *requiring* Zod at runtime; consumers wanting runtime validation install Zod themselves.
|
|
407
|
+
|
|
408
|
+
---
|
|
409
|
+
|
|
410
|
+
## Competitors and adjacent tools
|
|
411
|
+
|
|
412
|
+
| Tool | What they do well | Where they fall short for us |
|
|
413
|
+
|---|---|---|
|
|
414
|
+
| **Zod** | Excellent runtime validator, great TS inference, large ecosystem; ~all NekoStack consumers will run *generated* Zod at runtime. | Zod-as-source forces every non-Zod consumer through adapters that drift. We emit Zod as one output target; we don't make Zod the source format. |
|
|
415
|
+
| **TypeBox** | JSON-Schema-first, fast, multi-target. | DSL is verbose and less TS-native; closer in spirit to our approach but with different priorities. |
|
|
416
|
+
| **Effect-Schema** | Extremely powerful, principled, multi-output. | Steep learning curve, deep Effect ecosystem coupling, overkill for solo dev. |
|
|
417
|
+
| **io-ts** | Mature, principled. | Verbose, weaker TS inference than Zod, declining ecosystem. |
|
|
418
|
+
| **Valibot** | Tree-shakeable Zod alternative. | Same single-output limitation as Zod. |
|
|
419
|
+
| **JSON Schema (raw)** | Universal interchange format. | Hand-written JSON is hostile to author and review. |
|
|
420
|
+
| **TypeBox + zod-from-json-schema** | Multi-tool stack. | Toolchain becomes the spec — fragile, hard to evolve consistently. |
|
|
421
|
+
|
|
422
|
+
The right framing — corrected from the original audit critique:
|
|
423
|
+
|
|
424
|
+
> NekoStack is not "better Zod." It is a **schema IR + generator system** that emits Zod as one target alongside JSON Schema, OpenAPI, and TypeScript. The unique value is multi-output semantic consistency, not DSL ergonomics.
|
|
425
|
+
|
|
426
|
+
## How this fits the NekoStack
|
|
427
|
+
|
|
428
|
+
**Depends on:** Nothing within NekoStack. This is foundational — every other package may depend on it.
|
|
429
|
+
|
|
430
|
+
**Used by:**
|
|
431
|
+
- `@nekostack/api` — generates OpenAPI components.
|
|
432
|
+
- `@nekostack/form` — drives form rendering + validation.
|
|
433
|
+
- `@nekostack/cli` — validates command-line inputs.
|
|
434
|
+
- `@nekostack/codex` — validates entity shape definitions.
|
|
435
|
+
- `@nekostack/auth` — typifies token claims + `AccessDecision` shapes.
|
|
436
|
+
- `@nekostack/config` — boot-time env + runtime config validation.
|
|
437
|
+
- `@nekostack/events` — event payload shapes.
|
|
438
|
+
- `@nekostack/telemetry` — typed event catalog shapes.
|
|
439
|
+
- Effectively everything that crosses a system boundary.
|
|
440
|
+
|
|
441
|
+
## Design philosophy
|
|
442
|
+
|
|
443
|
+
- **IR is the contract.** DSL produces IR; generators consume IR; runtime executes IR-backed validators. The DSL is replaceable; the IR is not.
|
|
444
|
+
- **One source, many outputs — with honest semantic-loss tracking.** Outputs are derived. Where a derivation cannot be faithful, the system marks the gap explicitly.
|
|
445
|
+
- **TS-native DSL.** Schemas are TypeScript code, not a separate IDL file. Inference works for free.
|
|
446
|
+
- **Strict by default everywhere.** Unknown keys reject. Composition conflicts reject. Recursive schemas without IDs reject. Permissive behaviors are deliberate opt-ins.
|
|
447
|
+
- **Structured errors, normalized codes.** No raw strings, no Zod-leaked codes. The `Issue` shape is the contract.
|
|
448
|
+
- **Deterministic generated output.** Same source + same generator version → byte-identical output. Reviewable, diffable, CI-checkable.
|
|
449
|
+
- **Versioned schemas.** Every schema can carry an ID + version. Migrations (v0.8+) bridge versions; pre-migration, version is metadata that surfaces in errors + outputs.
|
|
450
|
+
|
|
451
|
+
## Architecture sketch
|
|
452
|
+
|
|
453
|
+
```
|
|
454
|
+
packages/schema/
|
|
455
|
+
├── src/
|
|
456
|
+
│ ├── ir/ # SchemaNode AST + normalization
|
|
457
|
+
│ │ ├── nodes.ts # node type definitions
|
|
458
|
+
│ │ ├── normalize.ts # builder → IR
|
|
459
|
+
│ │ └── serialize.ts # IR ↔ JSON (for registry / hashing)
|
|
460
|
+
│ ├── builders/ # public DSL: s.object(), s.string(), etc.
|
|
461
|
+
│ ├── generators/ # IR → outputs
|
|
462
|
+
│ │ ├── ts.ts # → TypeScript .d.ts
|
|
463
|
+
│ │ ├── zod.ts # → Zod validator code
|
|
464
|
+
│ │ ├── json-schema.ts # → JSON Schema draft 2020-12
|
|
465
|
+
│ │ ├── openapi.ts # → OpenAPI 3.1 component
|
|
466
|
+
│ │ └── header.ts # deterministic generated-file header
|
|
467
|
+
│ ├── runtime/ # validate() — IR-backed runtime
|
|
468
|
+
│ ├── composition/ # extend/pick/omit/partial/required/merge/override
|
|
469
|
+
│ ├── identity/ # $id, version, registry lookup
|
|
470
|
+
│ ├── errors/ # Issue, IssueCode, normalizers
|
|
471
|
+
│ └── cli/ # `neko schema generate / check / diff`
|
|
472
|
+
├── docs/ # deeper spec docs (deferred — see Roadmap)
|
|
473
|
+
├── tests/
|
|
474
|
+
│ ├── ir/ # IR normalization tests
|
|
475
|
+
│ ├── generators/ # snapshot + execution tests
|
|
476
|
+
│ ├── parity/ # semantic-parity (Neko ↔ Zod ↔ JSON Schema ↔ OpenAPI)
|
|
477
|
+
│ └── conformance/ # JSON Schema test suite, OpenAPI fixtures
|
|
478
|
+
└── README.md
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
Authoring a schema:
|
|
482
|
+
|
|
483
|
+
```ts
|
|
484
|
+
import { s } from '@nekostack/schema';
|
|
485
|
+
|
|
486
|
+
export const User = s.object({
|
|
487
|
+
id: s.string().uuid(),
|
|
488
|
+
email: s.string().email(),
|
|
489
|
+
displayName: s.string().min(1).max(50),
|
|
490
|
+
createdAt: s.isoDateTime(),
|
|
491
|
+
})
|
|
492
|
+
.id("com.nekostack.auth.User")
|
|
493
|
+
.version("1.0.0")
|
|
494
|
+
.describe("Authenticated user");
|
|
495
|
+
|
|
496
|
+
export type User = s.infer<typeof User>;
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
`neko schema generate` produces `.gen/user.zod.ts`, `.gen/user.json`, `.gen/user.openapi.json`, `.gen/user.d.ts` — each with the deterministic header.
|
|
500
|
+
|
|
501
|
+
## Roadmap
|
|
502
|
+
|
|
503
|
+
Revised after the design-audit pass. Migrations pushed from v0.5 to v0.8+ because the migration problem is much bigger than the original scope acknowledged.
|
|
504
|
+
|
|
505
|
+
### v0.1 — Core IR + builders
|
|
506
|
+
- `SchemaNode` IR + serialization.
|
|
507
|
+
- Primitive builders (string / number / boolean / literal / enum).
|
|
508
|
+
- Object builder (strict default), array builder, named schemas with `.id()` / `.version()` / `.describe()`.
|
|
509
|
+
- Metadata: id, version, description, deprecated flag.
|
|
510
|
+
- Basic `Issue` shape, normalized codes.
|
|
511
|
+
- TypeScript type inference (`s.infer<T>`).
|
|
512
|
+
- Builder unit tests + type inference tests.
|
|
513
|
+
|
|
514
|
+
### v0.2 — TypeScript + Zod generation
|
|
515
|
+
- TS generator (`.d.ts` output).
|
|
516
|
+
- Zod generator (Zod 3.x target initially).
|
|
517
|
+
- Deterministic header.
|
|
518
|
+
- Snapshot tests for generator output.
|
|
519
|
+
- Zod-execution tests (generated validator runs and matches fixtures).
|
|
520
|
+
|
|
521
|
+
### v0.3 — JSON Schema generation
|
|
522
|
+
- JSON Schema draft 2020-12 output.
|
|
523
|
+
- `$id` / `$defs` / `$ref` per identity rules.
|
|
524
|
+
- Portable constraint mapping (min/max/regex/format/etc.).
|
|
525
|
+
- Semantic-loss metadata for runtime-only refinements (`x-nekostack-runtime-refinement`).
|
|
526
|
+
- JSON Schema test-suite conformance.
|
|
527
|
+
|
|
528
|
+
### v0.4 — OpenAPI 3.1 generation
|
|
529
|
+
- OpenAPI 3.1 component schemas.
|
|
530
|
+
- Integration fixtures for `@nekostack/api`.
|
|
531
|
+
- Nullable / required mapping per the Absence Semantics table.
|
|
532
|
+
- Round-trip tests with `@redocly/openapi-core`.
|
|
533
|
+
|
|
534
|
+
### v0.5 — Composition
|
|
535
|
+
- `extend`, `pick`, `omit`, `partial`, `required`.
|
|
536
|
+
- Conflict-safe `merge` with explicit `override`.
|
|
537
|
+
- Composition tests covering conflict cases.
|
|
538
|
+
|
|
539
|
+
### v0.6 — Runtime validation
|
|
540
|
+
- `validate(schema, input)` returning `Result<T, Issue[]>`.
|
|
541
|
+
- Unknown-key handling (strict / stripUnknown / passthrough).
|
|
542
|
+
- Zod-backed execution (runtime delegates to generated Zod).
|
|
543
|
+
- Issue normalization (raw Zod issues → NekoStack `Issue` codes).
|
|
544
|
+
- Semantic-parity tests: same fixture validated against Neko runtime, generated Zod, JSON Schema validator, OpenAPI-compatible schema — expected failures match.
|
|
545
|
+
|
|
546
|
+
### v0.7 — Registry-lite
|
|
547
|
+
- Local schema registry (lookup by id + version).
|
|
548
|
+
- Schema diffing between two versions.
|
|
549
|
+
- Breaking-change detection per the matrix below.
|
|
550
|
+
- `neko schema check` (freshness) and `neko schema diff` (changes).
|
|
551
|
+
|
|
552
|
+
**Breaking-change matrix** (v0.7 implementation target):
|
|
553
|
+
|
|
554
|
+
| Change | Compatibility |
|
|
555
|
+
|---|---|
|
|
556
|
+
| Add optional field | non-breaking |
|
|
557
|
+
| Add required field | **breaking** (clients sending old shape now fail) |
|
|
558
|
+
| Remove field | **breaking** for consumers that read it |
|
|
559
|
+
| Make required → optional | non-breaking for producers; breaking for consumers expecting field |
|
|
560
|
+
| Add nullable (widen) | usually non-breaking for consumers |
|
|
561
|
+
| Remove nullable (narrow) | **breaking** for producers that emitted null |
|
|
562
|
+
| Widen enum (add value) | **breaking** for clients (must handle new value) |
|
|
563
|
+
| Narrow enum (remove value) | **breaking** for producers |
|
|
564
|
+
| Change scalar type (string → number) | **breaking** |
|
|
565
|
+
| Add runtime-only refinement | **breaking** for runtime consumers; invisible to JSON Schema/OpenAPI outputs |
|
|
566
|
+
| Rename field | **breaking** in all directions unless explicitly aliased |
|
|
567
|
+
| Add discriminated-union branch | non-breaking for consumers using fallback; breaking otherwise |
|
|
568
|
+
|
|
569
|
+
`neko schema diff <a> <b>` prints the change list with these annotations. CI in consuming projects can fail on any `breaking` change without a deliberate `--accept-breaking` flag.
|
|
570
|
+
|
|
571
|
+
### v0.8+ — Migrations
|
|
572
|
+
- Migration registry with version graph.
|
|
573
|
+
- Forward migrations between versioned schemas.
|
|
574
|
+
- Pre/post migration validation.
|
|
575
|
+
- Migration provenance and audit.
|
|
576
|
+
- Failure behavior + downgrade policy.
|
|
577
|
+
- Fixture tests per version pair.
|
|
578
|
+
|
|
579
|
+
### v1.0 — Stable API
|
|
580
|
+
- Full documentation site.
|
|
581
|
+
- Migration guide from Zod-as-source.
|
|
582
|
+
- Performance benchmarks vs Zod and TypeBox.
|
|
583
|
+
- Companion spec docs (deferred from v0.x): full Issue code catalog, generated-artifact policy details, testing strategy.
|
|
584
|
+
|
|
585
|
+
## Testing strategy
|
|
586
|
+
|
|
587
|
+
| Test category | When | Notes |
|
|
588
|
+
|---|---|---|
|
|
589
|
+
| Builder unit tests | v0.1 | shape construction, basic validation |
|
|
590
|
+
| Type inference tests | v0.1 | `expectTypeOf` / tsd-style assertions |
|
|
591
|
+
| IR normalization tests | v0.1 | builder → expected IR |
|
|
592
|
+
| Generator snapshot tests | v0.2+ | byte-identical output across runs |
|
|
593
|
+
| Zod execution tests | v0.2 | generated Zod actually validates correctly |
|
|
594
|
+
| JSON Schema conformance | v0.3 | passes JSON Schema test suite |
|
|
595
|
+
| OpenAPI fixture tests | v0.4 | round-trip with `@redocly/openapi-core` |
|
|
596
|
+
| **Semantic parity tests** | v0.6 | **most important** — same fixture validated four ways, failures match |
|
|
597
|
+
| Error normalization tests | v0.6 | Zod errors → NekoStack Issue codes |
|
|
598
|
+
| Recursive schema tests | v0.6 | `s.lazy()` references resolve through generators |
|
|
599
|
+
| Composition tests | v0.5 | merge conflicts, override semantics |
|
|
600
|
+
| Versioning + migration tests | v0.8+ | per-version fixtures, forward migration correctness |
|
|
601
|
+
| Property-based / fuzz tests | v1.0 | via `@nekostack/fuzz`: invariants like "any IR roundtrips to itself" |
|
|
602
|
+
|
|
603
|
+
Semantic parity is the load-bearing test category. Where the four outputs cannot match (runtime-only refinements being the prime case), the test asserts the *expected gap*, not equality.
|
|
604
|
+
|
|
605
|
+
## Product potential
|
|
606
|
+
|
|
607
|
+
**Internal use:** Mandatory. The whole stack rests on this.
|
|
608
|
+
|
|
609
|
+
**Open-source release:** Strong candidate. The multi-output semantic-consistency angle is genuinely undersupplied. MIT-licensed release with good docs could attract real users — particularly TS teams running multi-language stacks (TS frontend + Python backend, or generating SDKs for non-TS consumers).
|
|
610
|
+
|
|
611
|
+
**Commercial product (corrected from the original brief):** `@nekostack/schema` is **not the commercial product by itself**. It is the technical substrate for a future registry/governance product. A schema package becomes commercially interesting only when paired with:
|
|
612
|
+
|
|
613
|
+
- Hosted schema registry with version history
|
|
614
|
+
- Cross-version diffing + breaking-change detection
|
|
615
|
+
- SDK / codegen integration (à la Speakeasy / Stainless, but one layer earlier)
|
|
616
|
+
- Schema governance + team permissions
|
|
617
|
+
- CI integration (PR-level schema-change review)
|
|
618
|
+
- Changelog generation
|
|
619
|
+
- Compliance export
|
|
620
|
+
- Migration tracking
|
|
621
|
+
|
|
622
|
+
That commercial product would be a separate offering (e.g., `@nekostack/schema-cloud` or NekoSystems' enterprise tier consuming this) — built on top of this primitive. Not a near-term focus, but the path is real.
|
|
623
|
+
|
|
624
|
+
**Estimated effort to v1.0:** 6–12 weeks of focused work; more realistically 4–8 months at solo-dev cadence. Revised up from the original 4–8 / 3–6 because the IR + semantic-loss + identity + parity-test work added scope.
|
|
625
|
+
|
|
626
|
+
## Locked Design Decisions (v1.0 Freeze)
|
|
627
|
+
|
|
628
|
+
These decisions were explicitly closed to harden the public API surface for v1.0. They are not up for debate.
|
|
629
|
+
|
|
630
|
+
- **No Async Refinements.** `refineAsync` is explicitly rejected. Validation must remain a pure, synchronous, CPU-bound operation. If a check requires I/O (e.g., querying a database to see if a username is taken), it is **Business Logic**, not **Shape Validation**, and belongs in a Controller or Service.
|
|
631
|
+
- **Recursive Schema Cycles (`A → B → A`).** `s.lazy()` MUST take a string ID representing the target schema (e.g., `s.lazy("com.nekostack.User")`). The compiler does not attempt to resolve this during definition. It is resolved safely at runtime by querying the `schemaRegistry`. If the ID doesn't exist, it throws `recursive_reference_unresolved`.
|
|
632
|
+
- **Cross-package schema-id collision policy.** If `@nekostack/auth` and a consuming project both define `com.nekostack.auth.User@1.0.0` with different IR hashes, the registry `buildRegistry()` function will fail loudly with a `duplicate_schema_id` error. There is no silent overriding or "prefer local." The registry is the single source of truth.
|
|
633
|
+
|
|
634
|
+
## Still-open implementation decisions
|
|
635
|
+
|
|
636
|
+
The Implementation contracts section pins the load-bearing decisions. The list below names what is **not yet** decided — labeled honestly rather than pretending everything is closed. Each item is a real choice that will need to land before or during the relevant phase.
|
|
637
|
+
|
|
638
|
+
- **Discriminator value types.** Discriminated-union discriminators are presumed to be string literals (`s.literal("kind")`). Should we allow number literals too? Implications for OpenAPI emission TBD.
|
|
639
|
+
- **Workspace vs package vs hosted registry resolution.** Registry-lite (v0.7) is local-only. The path to a future hosted registry (a commercial offering above this package) needs a lookup-precedence rule. Deferred.
|
|
640
|
+
- **Per-tenant schema overlays.** Some SaaS consumers may want tenant-specific schema extensions (extra fields per tenant). Out of scope for v1; possibly Phase-9 / `@nekostack/entitlements`-adjacent.
|
|
641
|
+
- **Generator plugin contract.** Third-party generators (e.g., a future Prisma generator, GraphQL SDL emitter) need a stable contract. Deferred until v1.0 / post-v1.
|
|
642
|
+
- **Performance budgets.** No explicit perf targets yet. Will be set against Zod / TypeBox baselines.
|
|
643
|
+
|
|
644
|
+
Items here should graduate into the Implementation contracts section once decided. The list itself is the artifact: hiding open questions is worse than naming them.
|
|
645
|
+
|
|
646
|
+
## Status
|
|
647
|
+
|
|
648
|
+
- **Current:** Foundation underway. **Released through schema-v0.8.0** (~7.4k LOC, 1.3k tests).
|
|
649
|
+
- **Owner:** Cody (solo dev project).
|
|
650
|
+
- **Priority tier:** Foundation primitive.
|
|
651
|
+
- **Estimated learning return:** Very high. Schema-system design, type-level TypeScript, code-gen architecture, JSON Schema semantics, OpenAPI 3.1 internals, semantic-loss management, deterministic output discipline — all in one project.
|
|
652
|
+
- **Source thinking:**
|
|
653
|
+
- Initial audit: [`references/schema/design-audit-2026-05.md`](../../references/schema/design-audit-2026-05.md) — drove the IR / semantic-loss / identity / strict-by-default / migration-deferral additions.
|
|
654
|
+
- Follow-up audit: [`references/schema/design-audit-2026-05-followup.md`](../../references/schema/design-audit-2026-05-followup.md) — drove the Transform input/output split, Union policy, validate-vs-parse, coercion policy, IR hash, breaking-change matrix, and the Still-open section.
|
|
655
|
+
|
|
656
|
+
Future significant design changes should generate a similar audit document and link from here. The pattern is: audit → revise → preserve source thinking → label open decisions → repeat as design matures.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ArrayNode, SchemaNode } from "../ir/nodes.js";
|
|
2
|
+
import { Schema, type AnySchema } from "./schema.js";
|
|
3
|
+
import type { Input, Output } from "../types.js";
|
|
4
|
+
export declare class ArraySchema<E extends AnySchema> extends Schema<Input<E>[], Output<E>[], "required", "required"> {
|
|
5
|
+
private readonly elementSchema;
|
|
6
|
+
constructor(elementSchema: E, node?: ArrayNode);
|
|
7
|
+
protected clone(node: SchemaNode): this;
|
|
8
|
+
get element(): E;
|
|
9
|
+
min(items: number): ArraySchema<E>;
|
|
10
|
+
max(items: number): ArraySchema<E>;
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=array.d.ts.map
|