@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
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
# Migrations Contract (v0.8)
|
|
2
|
+
|
|
3
|
+
> The v0.8 schema-data migration surface — authoring shape, registry semantics, planner + verifier + stub contracts, the schema/CLI ownership boundary, and the explicit non-goals (no `apply`, no transform execution). Pairs with [`PHASE_PLAN_v0.8.md`](./PHASE_PLAN_v0.8.md); the plan locks decisions, this file documents the contract that resulted.
|
|
4
|
+
|
|
5
|
+
## Hard-locked boundary
|
|
6
|
+
|
|
7
|
+
v0.8 ships **migration planning + verification + stub generation only**. v0.8 does **NOT** ship:
|
|
8
|
+
|
|
9
|
+
- a `neko schema migrate apply` verb,
|
|
10
|
+
- any code path that calls `migration.transform(input)`,
|
|
11
|
+
- any other route by which v0.8 mutates data.
|
|
12
|
+
|
|
13
|
+
Migration execution is deferred to **v0.9+** behind its own plan and explicit safety review.
|
|
14
|
+
|
|
15
|
+
**Module top-level evaluation ≠ transform execution.** Migration files are loaded through `tsx` during discovery (Step 19), and any top-level code inside a `*.migration.ts` file evaluates at load time. Top-level evaluation failures are classified as `runtime_error` load failures by the existing tsx-loader contract. The authoring guidance (below) requires migration files to keep top-level code declarative and side-effect-free.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Authored migration file shape
|
|
20
|
+
|
|
21
|
+
The locked shape of a `*.migration.ts` file:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
/**
|
|
25
|
+
* @migration by @nekostack/schema
|
|
26
|
+
* schemaId: com.x.User
|
|
27
|
+
* fromVersion: 1.0.0
|
|
28
|
+
* toVersion: 2.0.0
|
|
29
|
+
* fromIrHash: sha256:<64-hex>
|
|
30
|
+
* toIrHash: sha256:<64-hex>
|
|
31
|
+
* fromSourceHash: sha256:<64-hex>
|
|
32
|
+
* toSourceHash: sha256:<64-hex>
|
|
33
|
+
* generator: neko-schema-migrate-stub
|
|
34
|
+
* generatorVersion: @nekostack/schema@0.8.0
|
|
35
|
+
*
|
|
36
|
+
* DO NOT REMOVE THE HEADER. Authors EDIT THE BODY.
|
|
37
|
+
*/
|
|
38
|
+
import type { Migration } from "@nekostack/schema/cli";
|
|
39
|
+
|
|
40
|
+
const migration: Migration<"com.x.User", "1.0.0", "2.0.0"> = {
|
|
41
|
+
schemaId: "com.x.User",
|
|
42
|
+
from: "1.0.0",
|
|
43
|
+
to: "2.0.0",
|
|
44
|
+
transform(input) {
|
|
45
|
+
// Author fills this in. v0.8 NEVER invokes this function.
|
|
46
|
+
throw new Error("Not yet implemented");
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export default migration;
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### `Migration<...>` type
|
|
54
|
+
|
|
55
|
+
Defined in [`src/migrations/types.ts`](../src/migrations/types.ts):
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
interface Migration<
|
|
59
|
+
SchemaId extends string = string,
|
|
60
|
+
FromVersion extends string = string,
|
|
61
|
+
ToVersion extends string = string,
|
|
62
|
+
Input = unknown,
|
|
63
|
+
Output = unknown,
|
|
64
|
+
> {
|
|
65
|
+
readonly schemaId: SchemaId;
|
|
66
|
+
readonly from: FromVersion;
|
|
67
|
+
readonly to: ToVersion;
|
|
68
|
+
readonly transform: (input: Input) => Output;
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**No `down` / `reverse` field** — forward-only migrations are v0.8 (Decision #2). Bidirectional migrations are a separate authorship surface and not a v0.8 concern.
|
|
73
|
+
|
|
74
|
+
**One `schemaId` per migration** — cross-schema migrations are out of scope (Decision #4). Splitting `User → User + Profile` is two unrelated plans the consumer composes.
|
|
75
|
+
|
|
76
|
+
### `Migration` import — `/cli` subpath only
|
|
77
|
+
|
|
78
|
+
The `Migration` type is imported from `@nekostack/schema/cli`, **not** from root `@nekostack/schema`. Decision #6: the v0.8 surface is package-internal; engine-swap-safety lives at the root, not at this subpath. The negative root-leakage gate in [`tests/public-surface.test.ts`](../tests/public-surface.test.ts) asserts root carries none of the 28 v0.8 names (10 runtime + 18 types).
|
|
79
|
+
|
|
80
|
+
### Top-level code rule
|
|
81
|
+
|
|
82
|
+
The header is the contract; the body is the author's. The body MUST keep its top-level code:
|
|
83
|
+
|
|
84
|
+
- declarative — no `if`/`switch`/`for` outside the `transform` function,
|
|
85
|
+
- side-effect-free — no `console.log`, no `fs`, no `process.exit`, no `import()` calls.
|
|
86
|
+
|
|
87
|
+
The eventual `transform` body is the only place where data work happens, and **v0.8 never invokes it**.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Provenance header
|
|
92
|
+
|
|
93
|
+
The header carries 9 fields. All are required — v0.8 has no backward-compatibility window for missing fields (Decision #7).
|
|
94
|
+
|
|
95
|
+
| Field | Source |
|
|
96
|
+
|--------------------|-------------------------------------------------------------------------|
|
|
97
|
+
| `@migration by` | Always `@nekostack/schema` |
|
|
98
|
+
| `schemaId` | reverse-DNS id of the schema this migration targets |
|
|
99
|
+
| `fromVersion` | source semver-shaped string |
|
|
100
|
+
| `toVersion` | destination semver-shaped string |
|
|
101
|
+
| `fromIrHash` | `sha256:<hex>` of canonical IR of the from-version schema |
|
|
102
|
+
| `toIrHash` | `sha256:<hex>` of canonical IR of the to-version schema |
|
|
103
|
+
| `fromSourceHash` | `sha256:<hex>` of raw UTF-8 source bytes of the from-version schema |
|
|
104
|
+
| `toSourceHash` | `sha256:<hex>` of raw UTF-8 source bytes of the to-version schema |
|
|
105
|
+
| `generator` | Always `neko-schema-migrate-stub` |
|
|
106
|
+
| `generatorVersion` | `@nekostack/schema@<package-version>` |
|
|
107
|
+
|
|
108
|
+
[`parseMigrationProvenanceFromText(content)`](../src/migrations/parse-provenance.ts) reads the header. The carrier is **JSDoc-only** — migration files are TypeScript modules, not data documents.
|
|
109
|
+
|
|
110
|
+
### Fail-loud, never silent
|
|
111
|
+
|
|
112
|
+
A `.migration.ts` file exists specifically to declare a `(schemaId, fromVersion, toVersion)` transition. Missing or malformed provenance is treated as an **invalid declaration**, not a silent skip — silent skip would let a broken migration mask itself as "no migration found." `buildMigrationRegistry` returns `Result.failure` with an `integrity_error` Issue carrying `metadata.reason`:
|
|
113
|
+
|
|
114
|
+
| `metadata.reason` | When |
|
|
115
|
+
|----------------------------------|------------------------------------------------------------|
|
|
116
|
+
| `unknown_format` | File doesn't start with `/**` JSDoc |
|
|
117
|
+
| `missing_migration_provenance` | No JSDoc block at all |
|
|
118
|
+
| `missing_field` | A required field is missing |
|
|
119
|
+
| `malformed_hash` | A hash field doesn't match `sha256:<64-hex>` |
|
|
120
|
+
| `malformed_field` | A field's value is empty |
|
|
121
|
+
|
|
122
|
+
This is an **intentional departure from v0.7 Decision #5** (which tolerates anonymous *schemas* because a schema file may legitimately export both indexed schemas and helper schemas). A migration file has no analogous "helper migration" use case.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Migration registry — identity and indexing
|
|
127
|
+
|
|
128
|
+
`buildMigrationRegistry(entries: readonly MigrationSourceEntry[]): Result<MigrationRegistry>` ([`src/migrations/build-migration-registry.ts`](../src/migrations/build-migration-registry.ts)) constructs the three-level lookup:
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
MigrationRegistry = ReadonlyMap<
|
|
132
|
+
schemaId,
|
|
133
|
+
ReadonlyMap<
|
|
134
|
+
fromVersion,
|
|
135
|
+
ReadonlyMap<toVersion, MigrationEntry>
|
|
136
|
+
>
|
|
137
|
+
>
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Identity
|
|
141
|
+
|
|
142
|
+
A migration's identity is the `(schemaId, fromVersion, toVersion)` triple (Decision #8). Storage keys all three levels by this triple; provenance hashes are **binding** information, not identity (they let the verifier detect when the migration was authored against a schema state that has since changed).
|
|
143
|
+
|
|
144
|
+
### Duplicate detection
|
|
145
|
+
|
|
146
|
+
Two migration files claiming the same triple surface as `Result.failure` with a `duplicate_migration` Issue:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
{
|
|
150
|
+
code: "duplicate_migration",
|
|
151
|
+
path: [],
|
|
152
|
+
message: "Duplicate migration for `com.x.User` 1.0.0 → 2.0.0 found in: a.migration.ts, b.migration.ts",
|
|
153
|
+
severity: "error",
|
|
154
|
+
metadata: {
|
|
155
|
+
schemaId: "com.x.User",
|
|
156
|
+
fromVersion: "1.0.0",
|
|
157
|
+
toVersion: "2.0.0",
|
|
158
|
+
sourcePaths: ["a.migration.ts", "b.migration.ts"],
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**First-seen wins** on a duplicate — the partial map isn't torn. Same rule as `buildRegistry`'s duplicate handling for v0.7's `duplicate_schema_id`.
|
|
164
|
+
|
|
165
|
+
### Multi-failure aggregation
|
|
166
|
+
|
|
167
|
+
The builder **never short-circuits** — duplicate-id Issues + malformed-provenance Issues are collected together in one `Result.failure`. The CLI dispatcher (Step 21+) renders a single human-readable report.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## Planner — `planMigration`
|
|
172
|
+
|
|
173
|
+
[`src/migrations/plan-migration.ts`](../src/migrations/plan-migration.ts). The Round-3 locked signature consumes both registries plus the operand triple:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
planMigration({
|
|
177
|
+
schemaRegistry,
|
|
178
|
+
migrationRegistry,
|
|
179
|
+
schemaId,
|
|
180
|
+
fromVersion,
|
|
181
|
+
toVersion,
|
|
182
|
+
}): Result<MigrationPlan>
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Behavior
|
|
186
|
+
|
|
187
|
+
1. **Resolve endpoints** in `schemaRegistry` via `findSchema`. Either missing → `Result.failure` with `migration_missing_endpoint`. No diff is computed when an endpoint is missing.
|
|
188
|
+
2. **Compute the diff** via the v0.7 classifier `diffNodes(from.node, to.node)` (see [`DIFF_CLASSIFICATION.md`](./DIFF_CLASSIFICATION.md)). `worstSeverity` aggregation uses the same precedence `breaking > additive > cosmetic`, `null` when empty.
|
|
189
|
+
3. **Look up the exact migration** for the triple (if any).
|
|
190
|
+
4. **Severity-gate the chain requirement** per Decision #10:
|
|
191
|
+
|
|
192
|
+
| `worstSeverity` | Chain | Notes / Failure |
|
|
193
|
+
|-----------------|----------------------------------------|-------------------------------------------------------------------------------------------------------|
|
|
194
|
+
| `null` | empty | If an exact migration exists for the pair, attach an `over_specified` `PlanNote`. |
|
|
195
|
+
| `"cosmetic"` | empty | Same `over_specified` rule. |
|
|
196
|
+
| `"additive"` | empty (no migration) OR `[exact]` | If an exact migration is registered, include it. Otherwise empty + `additive_no_migration` `PlanNote`.|
|
|
197
|
+
| `"breaking"` | **required** — DFS over adjacency map | 0 chains → `migration_not_found` (no migrations for schemaId) / `migration_chain_broken` (some exist but no path). 1 chain → success. 2+ chains → `migration_ambiguous_chain` (planner refuses to pick).|
|
|
198
|
+
|
|
199
|
+
### `MigrationPlan` shape
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
interface MigrationPlan {
|
|
203
|
+
readonly schemaId: string;
|
|
204
|
+
readonly chain: readonly MigrationEntry[];
|
|
205
|
+
readonly versionPath: readonly string[]; // [from, ...intermediates, to]
|
|
206
|
+
readonly worstSeverity: DiffSeverity | null;
|
|
207
|
+
readonly notes: readonly PlanNote[];
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`versionPath` always carries both endpoints. For an empty chain it's `[fromVersion, toVersion]`. For a non-empty chain it's derived from the chain entries' `toVersion`s.
|
|
212
|
+
|
|
213
|
+
### `PlanNote` kinds
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
type PlanNote =
|
|
217
|
+
| { kind: "over_specified"; migration: MigrationEntry }
|
|
218
|
+
| { kind: "additive_no_migration"; worstSeverity: DiffSeverity };
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Notes record *why* the plan came out the way it did when the chain decision is non-obvious.
|
|
222
|
+
|
|
223
|
+
### Failure codes (planner)
|
|
224
|
+
|
|
225
|
+
| Code | Condition |
|
|
226
|
+
|---------------------------------|----------------------------------------------------------------------------|
|
|
227
|
+
| `migration_missing_endpoint` | Either `fromVersion` or `toVersion` is absent from the schema registry. |
|
|
228
|
+
| `migration_not_found` | `worstSeverity` is `breaking` and no migrations are registered for the id. |
|
|
229
|
+
| `migration_chain_broken` | `worstSeverity` is `breaking`, migrations exist but no path bridges. |
|
|
230
|
+
| `migration_ambiguous_chain` | `worstSeverity` is `breaking` and two or more chains both reach the target.|
|
|
231
|
+
|
|
232
|
+
### `transform` is never called
|
|
233
|
+
|
|
234
|
+
Chain resolution is **structural**. The planner walks `(schemaId, fromVersion) → Map<toVersion, MigrationEntry>` adjacency and emits an ordered chain of entries. It does not — and cannot — call `migration.transform`. The static-scan purity gate ([`tests/migrations/handler-purity.test.ts`](../tests/migrations/handler-purity.test.ts)) deny-lists `.transform(` over the planner's full reach.
|
|
235
|
+
|
|
236
|
+
### `UnsupportedNodeKindError` propagates
|
|
237
|
+
|
|
238
|
+
If `diffNodes` is given an IR with an unsupported kind (`date` / `union` / `recursiveRef` / `transform`), the planner does **not** catch the throw — it propagates to the CLI dispatcher, which maps it to a non-zero exit code at the CLI boundary. Same fail-loud discipline as v0.3 / v0.6 generators.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Verifier — `verifyMigrationProvenance`
|
|
243
|
+
|
|
244
|
+
[`src/migrations/verify-provenance.ts`](../src/migrations/verify-provenance.ts):
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
verifyMigrationProvenance({ schemaRegistry, migrationRegistry }): Result<VerificationResult>
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Verdict matrix
|
|
251
|
+
|
|
252
|
+
For every `MigrationEntry`, compare recorded provenance against current schema registry endpoints. Mirrors the v0.7 two-hash freshness matrix one row at a time:
|
|
253
|
+
|
|
254
|
+
| irHash (both endpoints) | sourceHash (both endpoints) | Verdict |
|
|
255
|
+
|-------------------------|------------------------------|---------------------|
|
|
256
|
+
| match | match | `bound` |
|
|
257
|
+
| match | at least one differs | `cosmetic_drift` |
|
|
258
|
+
| at least one differs | (any) | `drift` |
|
|
259
|
+
| endpoint absent | n/a | `missing_endpoint` |
|
|
260
|
+
|
|
261
|
+
Per-verdict shape (discriminated union, `status` field):
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
{ status: "bound" | "cosmetic_drift" | "drift" | "missing_endpoint",
|
|
265
|
+
sourcePath, schemaId, fromVersion, toVersion }
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### Result envelope
|
|
269
|
+
|
|
270
|
+
- `Result.success` when every verdict is `bound` or `cosmetic_drift`. **`cosmetic_drift` does not fail the run** — CLI warns on stderr; exit stays `SUCCESS`.
|
|
271
|
+
- `Result.failure` with one `Issue` per `drift` / `missing_endpoint` otherwise. CLI maps to `LOGICAL_FAILURE`.
|
|
272
|
+
|
|
273
|
+
### Scope of "verification"
|
|
274
|
+
|
|
275
|
+
**Provenance + chain integrity only.** The verifier does NOT prove transform correctness. A migration whose `transform` is `throw new Error("Not yet implemented")` verifies just as cleanly as a fully-authored one, as long as the provenance hashes bind. The verifier is a `provenance-says-what-it-says` check, not a behavior check. Transform behavior is v0.9+.
|
|
276
|
+
|
|
277
|
+
### Failure codes (verifier)
|
|
278
|
+
|
|
279
|
+
| Code | Condition |
|
|
280
|
+
|------------------------------|----------------------------------------------------------------------------------------|
|
|
281
|
+
| `migration_drift` | irHash mismatch at at least one endpoint. |
|
|
282
|
+
| `migration_missing_endpoint` | A migration references a schema version not in the registry. Same code as the planner. |
|
|
283
|
+
|
|
284
|
+
`cosmetic_drift` does not surface as an Issue — it lives on the success branch's `verdicts[]` and `summary.cosmetic_drift` only.
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## Stub — `stubMigration`
|
|
289
|
+
|
|
290
|
+
[`src/migrations/stub.ts`](../src/migrations/stub.ts):
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
stubMigration({ schemaRegistry, schemaId, fromVersion, toVersion }): Result<MigrationStub>
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Pure file-*content* generator. The CLI does the filesystem write (Step 23).
|
|
297
|
+
|
|
298
|
+
### Suggested path
|
|
299
|
+
|
|
300
|
+
```text
|
|
301
|
+
<schema-dir>/migrations/<basename>.<from-slug>-to-<to-slug>.migration.ts
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
- `<basename>` strips `.schema.{ts,js,mts,cts}` from the from-version schema's filename.
|
|
305
|
+
- `<from-slug>` / `<to-slug>` use the **v0.7-compatible slug rule** from `src/registry/handlers/generate.ts` (Round-2 lock):
|
|
306
|
+
```text
|
|
307
|
+
lowercase → non-alphanumeric runs collapse to "-" → trim leading/trailing "-"
|
|
308
|
+
```
|
|
309
|
+
- Handles prerelease/build markers cleanly: `1.0.0-beta.1` → `1-0-0-beta-1`, `2.0.0+build.5` → `2-0-0-build-5`.
|
|
310
|
+
|
|
311
|
+
### Generated content
|
|
312
|
+
|
|
313
|
+
- Full 9-field JSDoc provenance header padded for visual alignment (matches the v0.2/v0.3 generator-header style).
|
|
314
|
+
- `import type { Migration } from "@nekostack/schema/cli"` — NOT root.
|
|
315
|
+
- Typed `Migration<schemaId, fromVersion, toVersion>` declaration.
|
|
316
|
+
- `transform(input)` body that throws `"Not yet implemented"`. Authors edit the body; v0.8 never invokes it.
|
|
317
|
+
- `export default migration`.
|
|
318
|
+
|
|
319
|
+
The stub generator is pure — it does not write the file. The CLI's `stub` verb (Step 23) does `mkdir -p` + `writeFile` and **refuses to overwrite** an existing file at the suggested path (unlike `generate`'s overwrite-by-default behavior; `generate` overwrites generated artifacts, but `stub` would overwrite hand-authored code).
|
|
320
|
+
|
|
321
|
+
### Failure
|
|
322
|
+
|
|
323
|
+
| Code | Condition |
|
|
324
|
+
|------------------------------|---------------------------------------------------------------------------|
|
|
325
|
+
| `migration_missing_endpoint` | Either `fromVersion` or `toVersion` is absent from the schema registry. |
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## Schema/CLI ownership boundary
|
|
330
|
+
|
|
331
|
+
Master plan Decision #1 (continued from v0.6 / v0.7):
|
|
332
|
+
|
|
333
|
+
| Concern | Owner |
|
|
334
|
+
|--------------------------------------------------|------------------------|
|
|
335
|
+
| Pure registry construction, planning, verification, stub-content generation | `@nekostack/schema` (v0.8 surface) |
|
|
336
|
+
| Filesystem reads (`*.schema.ts`, `*.migration.ts`, generated artifacts) | `@nekostack/cli` |
|
|
337
|
+
| Schema/migration module loading via `tsx` | `@nekostack/cli` |
|
|
338
|
+
| Filesystem writes (stub files) | `@nekostack/cli` |
|
|
339
|
+
| stdout / stderr formatting | `@nekostack/cli` |
|
|
340
|
+
| Process exit codes | `@nekostack/cli` |
|
|
341
|
+
| `migration.transform` execution | **v0.9+**, no owner in v0.8 |
|
|
342
|
+
|
|
343
|
+
The schema package is **data-in / data-out**. No `fs.*`, no dynamic `import()`, no `process.*`, no `console.*`. The static + runtime purity gate in [`tests/migrations/handler-purity.test.ts`](../tests/migrations/handler-purity.test.ts) covers all four migration handlers and their immediate reach (9 modules total) plus `.transform(` as a forbidden call pattern.
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## Surface boundary
|
|
348
|
+
|
|
349
|
+
| Path | What it exposes |
|
|
350
|
+
|---------------------------------|------------------------------------------------------------------------------------------------|
|
|
351
|
+
| `@nekostack/schema` | v0.6 public consumer API. **No v0.7 / v0.8 surface names appear here.** |
|
|
352
|
+
| `@nekostack/schema/cli` | Package-internal integration surface for `@nekostack/cli`. Exposes v0.7 + v0.8 surfaces. |
|
|
353
|
+
|
|
354
|
+
The boundary is gated by two complementary test files:
|
|
355
|
+
|
|
356
|
+
- [`tests/registry-surface.test.ts`](../tests/registry-surface.test.ts) — positive gate. Asserts every v0.7 + v0.8 runtime name and type is reachable through `@nekostack/schema/cli`.
|
|
357
|
+
- [`tests/public-surface.test.ts`](../tests/public-surface.test.ts) — negative gate. Asserts every v0.7 + v0.8 name is **absent** from `@nekostack/schema`. Type-level coverage uses `@ts-expect-error` directives — any future leakage makes a directive unused, which is itself a typecheck error.
|
|
358
|
+
|
|
359
|
+
Wiring: `package.json` `exports` map declares both `"."` (root) and `"./cli"`; the `/cli` path resolves to [`src/cli-integration.ts`](../src/cli-integration.ts) which re-exports the v0.7 + v0.8 surfaces.
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
## Explicit non-goals
|
|
364
|
+
|
|
365
|
+
The following are **out of scope** for v0.8. They live either in a later schema phase, in a sibling package, or — for the highest-risk items — behind v0.9+'s own plan and safety review:
|
|
366
|
+
|
|
367
|
+
| Out-of-scope item | Why deferred |
|
|
368
|
+
|----------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
|
|
369
|
+
| **Migration execution (`apply`)** | The whole hard-locked boundary. v0.9+ revisits behind a separate plan with pre/post validation, audit log emission, rollback semantics, dry-run/apply split, online/offline. |
|
|
370
|
+
| **Reversible / down migrations** | Bidirectional migrations are a separate authorship surface. Every `down` needs its own authorship + test + verification. |
|
|
371
|
+
| **Cross-schema migrations** | One `schemaId` per migration. Splitting `User → User + Profile` is two unrelated plans the consumer composes. |
|
|
372
|
+
| **Database DDL migrations (`ALTER TABLE`)** | Owned by [`@nekostack/migrate`](../../migrate) per `BOUNDARIES.md` §25. v0.8's planner produces plans that `@nekostack/migrate` could consume, but DDL execution belongs there. |
|
|
373
|
+
| **Online migrations** | Even when v0.9+ adds `apply`, the apply phase's plan decides offline-vs-online. v0.8 does not pre-decide. |
|
|
374
|
+
| **Branching migrations / version trees** | Linear version chains only. `migration_ambiguous_chain` is fail-loud, not auto-resolved. |
|
|
375
|
+
| **Multi-hop skip migrations** | Forced through intermediate versions; the planner enumerates simple paths, not skip-edges. |
|
|
376
|
+
| **Transform correctness proof** | The verifier checks provenance + chain integrity. v0.8 cannot inspect a function body to know whether it correctly maps `s.output<v1>` to `s.output<v2>`. Authors write their own unit tests. |
|
|
377
|
+
| **Migration scheduling / orchestration** | v1.0+. |
|
|
378
|
+
| **Distributed migration coordination** | Single-process planner. No locks, leases, cluster awareness. |
|
|
379
|
+
| **Authoring idioms / helper-library conventions** | v0.8 locks the file shape, path convention, provenance carrier, and type signature. Author idioms (helper libraries, snapshot strategies) land separately if the community converges on a pattern. |
|
|
380
|
+
|
|
381
|
+
---
|
|
382
|
+
|
|
383
|
+
## Reference
|
|
384
|
+
|
|
385
|
+
### Implementation files
|
|
386
|
+
|
|
387
|
+
| Surface | File |
|
|
388
|
+
|------------------------------------------------|----------------------------------------------------------------------------------------|
|
|
389
|
+
| Types | [`../src/migrations/types.ts`](../src/migrations/types.ts) |
|
|
390
|
+
| Provenance parser | [`../src/migrations/parse-provenance.ts`](../src/migrations/parse-provenance.ts) |
|
|
391
|
+
| Registry builder | [`../src/migrations/build-migration-registry.ts`](../src/migrations/build-migration-registry.ts) |
|
|
392
|
+
| Planner | [`../src/migrations/plan-migration.ts`](../src/migrations/plan-migration.ts) |
|
|
393
|
+
| Verifier | [`../src/migrations/verify-provenance.ts`](../src/migrations/verify-provenance.ts) |
|
|
394
|
+
| Stub generator | [`../src/migrations/stub.ts`](../src/migrations/stub.ts) |
|
|
395
|
+
| Handlers — `list`, `plan`, `verify`, `stub` | [`../src/migrations/handlers/`](../src/migrations/handlers/) |
|
|
396
|
+
| Diff classifier (shared with v0.7) | [`../src/registry/diff.ts`](../src/registry/diff.ts) |
|
|
397
|
+
| Integration barrel | [`../src/cli-integration.ts`](../src/cli-integration.ts) |
|
|
398
|
+
|
|
399
|
+
### Phase + contract docs
|
|
400
|
+
|
|
401
|
+
| Doc | Subject |
|
|
402
|
+
|-----------------------------------------------|----------------------------------------------------------|
|
|
403
|
+
| [`./PHASE_PLAN_v0.8.md`](./PHASE_PLAN_v0.8.md) | Locked decisions, sequencing, validation gates |
|
|
404
|
+
| [`./DIFF_CLASSIFICATION.md`](./DIFF_CLASSIFICATION.md) | The classifier the planner consumes |
|
|
405
|
+
| [`./REGISTRY.md`](./REGISTRY.md) | v0.7 registry / freshness / generation contract |
|
|
406
|
+
| [`./HEADER_FORMAT.md`](./HEADER_FORMAT.md) | The JSDoc provenance-header carrier (v0.2-era origin) |
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Migrating to NekoStack Schema
|
|
2
|
+
|
|
3
|
+
> The definitive guide to moving from Zod (or raw JSON Schema) to a single-source-of-truth architecture.
|
|
4
|
+
|
|
5
|
+
## The Problem: The "Silent Lie" of Modern Web Dev
|
|
6
|
+
|
|
7
|
+
Every senior engineer has the same horror story: A developer adds a new optional field to a database table. They update the Prisma model. They update the TypeScript interface. They forget to update the Zod validator on the API route.
|
|
8
|
+
|
|
9
|
+
Two months later, an integration partner complains their webhook is broken because the payload is missing a field the OpenAPI docs promised. The docs were generated from an outdated interface, the API is validating against an outdated Zod schema, and the database is rejecting nulls.
|
|
10
|
+
|
|
11
|
+
You have a "Silent Lie" in your architecture. Your contracts drifted because **you were trying to maintain the same shape in three different languages.**
|
|
12
|
+
|
|
13
|
+
## The Solution: The Intermediate Representation (IR)
|
|
14
|
+
|
|
15
|
+
`@nekostack/schema` solves this by forcing you to define the shape **once**, in a specialized DSL. That definition is compiled into an Intermediate Representation (IR).
|
|
16
|
+
|
|
17
|
+
From that IR, the system generates:
|
|
18
|
+
1. Your Zod validators.
|
|
19
|
+
2. Your TypeScript types.
|
|
20
|
+
3. Your OpenAPI 3.1 Components.
|
|
21
|
+
4. Your JSON Schema specifications.
|
|
22
|
+
|
|
23
|
+
When you change the definition, **every artifact updates instantly**. Drift becomes mathematically impossible.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Step 1: The Basic Rewrite
|
|
28
|
+
|
|
29
|
+
Let's convert a standard Zod schema to NekoStack.
|
|
30
|
+
|
|
31
|
+
**Before (Zod):**
|
|
32
|
+
```ts
|
|
33
|
+
import { z } from "zod";
|
|
34
|
+
|
|
35
|
+
export const UserSchema = z.object({
|
|
36
|
+
id: z.string().uuid(),
|
|
37
|
+
email: z.string().email(),
|
|
38
|
+
role: z.enum(["admin", "member"]).default("member")
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export type User = z.infer<typeof UserSchema>;
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**After (NekoStack):**
|
|
45
|
+
```ts
|
|
46
|
+
import { s } from "@nekostack/schema";
|
|
47
|
+
|
|
48
|
+
export const User = s.object({
|
|
49
|
+
id: s.string().uuid(),
|
|
50
|
+
email: s.string().email(),
|
|
51
|
+
role: s.enum(["admin", "member"]).default("member")
|
|
52
|
+
})
|
|
53
|
+
.id("com.nekostack.auth.User")
|
|
54
|
+
.version("1.0.0")
|
|
55
|
+
.describe("The authenticated user record.");
|
|
56
|
+
|
|
57
|
+
// s.infer works exactly like z.infer
|
|
58
|
+
export type UserType = s.infer<typeof User>;
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### What changed?
|
|
62
|
+
1. `z` became `s`. The DSL is nearly identical.
|
|
63
|
+
2. **The "Insurance Policy":** We added `.id()`, `.version()`, and `.describe()`.
|
|
64
|
+
|
|
65
|
+
**Why the ID?** Zod schemas are anonymous. If you want to safely migrate millions of user records from v1.0.0 to v2.0.0, the system needs to know *what* it is migrating. The reverse-DNS ID (`com.nekostack.auth.User`) gives your data a permanent address in the registry, ensuring you never accidentally apply a `Tenant` migration to a `User` record.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Step 2: The "Magic Moment" (Generation)
|
|
70
|
+
|
|
71
|
+
Once you define your schema, you no longer write types or OpenAPI specs by hand.
|
|
72
|
+
|
|
73
|
+
Run the CLI:
|
|
74
|
+
```bash
|
|
75
|
+
neko schema generate src/schemas/user.schema.ts
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
This generates four files alongside your source:
|
|
79
|
+
* `user.zod.ts`: A fully functioning Zod 3.x schema.
|
|
80
|
+
* `user.d.ts`: Your clean TypeScript definitions.
|
|
81
|
+
* `user.openapi.json`: Your OpenAPI 3.1 component.
|
|
82
|
+
* `user.json.schema.json`: Your Draft 2020-12 specification.
|
|
83
|
+
|
|
84
|
+
You just automated away 20% of your maintenance work.
|
|
85
|
+
|
|
86
|
+
### Visualizing the Truth
|
|
87
|
+
**Why the IR is not a lossy process:** In many generators, you lose metadata (like JSDoc descriptions or custom validation tags) when moving from a schema to an OpenAPI spec. NekoStack IR is designed as a **Super-Set.** It captures the structural intent *and* the descriptive metadata of your schema. When you generate an OpenAPI spec, you aren't guessing—you are projecting the IR into the OpenAPI standard. If the IR contains a rule that OpenAPI cannot represent, NekoStack will stop the build, preventing you from shipping an API doc that lies about your actual runtime behavior.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Step 3: Runtime Validation
|
|
92
|
+
|
|
93
|
+
You do **not** import Zod to validate your data. `@nekostack/schema` absorbs the workflow to provide a stricter, safer guarantee.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { parse, safeParse, validate } from "@nekostack/schema";
|
|
97
|
+
|
|
98
|
+
// 1. Parse (Throws on failure, fills defaults)
|
|
99
|
+
const user = parse(User, req.body);
|
|
100
|
+
|
|
101
|
+
// 2. SafeParse (Returns a Result, fills defaults)
|
|
102
|
+
const result = safeParse(User, req.body);
|
|
103
|
+
if (!result.success) {
|
|
104
|
+
// result.issues uses stable NekoStack IssueCodes, not raw Zod strings
|
|
105
|
+
console.log(result.issues[0].code); // "invalid_type"
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 3. Validate (Structural check only. Does NOT fill defaults)
|
|
109
|
+
const isValid = validate(User, req.body);
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### The "Validate vs. Parse" Split
|
|
113
|
+
Zod conflates "checking" data with "mutating/filling" data. NekoStack splits them. If you are validating an incoming webhook, you want `parse` (to fill the default `role: "member"`). If you are verifying a database row you just read, you want `validate` (to ensure it is structurally sound without accidentally overwriting data).
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Step 4: The Safety Net (CI/CD)
|
|
118
|
+
|
|
119
|
+
The true value of NekoStack isn't saving keystrokes; it's saving production.
|
|
120
|
+
|
|
121
|
+
When you run `neko schema check` in your CI pipeline, the system verifies the **IR Hash** embedded in every generated artifact.
|
|
122
|
+
|
|
123
|
+
If a developer changes the `User` schema but forgets to run `generate` and commits the PR, the CI build will fail instantly with a `Stale Artifact` error. **The Silent Lie is dead.**
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Deep Dive: Nuances & Gaps
|
|
128
|
+
|
|
129
|
+
While moving from Zod is mostly copy-paste, you must be aware of NekoStack's stricter safety boundaries:
|
|
130
|
+
|
|
131
|
+
### 1. `null` vs `undefined`
|
|
132
|
+
Zod is often loose with absence. NekoStack is literal.
|
|
133
|
+
* `.optional()` means the key can be missing (undefined).
|
|
134
|
+
* `.nullable()` means the value can literally be `null`.
|
|
135
|
+
* `.nullish()` means both.
|
|
136
|
+
If you define a field as `.default("x")`, NekoStack treats it as **input-optional, but output-required**.
|
|
137
|
+
|
|
138
|
+
### 2. Transform Precedence
|
|
139
|
+
When a field has both a `.default()` and a `.transform()`, **NekoStack applies the default first**. The transform function will always receive the defaulted value, never `undefined`. This ensures your database mutations have a predictable starting state, an edge case raw Zod leaves ambiguous.
|
|
140
|
+
|
|
141
|
+
### 3. Recursive Schemas (`s.lazy()`)
|
|
142
|
+
In Zod, you can create infinitely recursive anonymous schemas. In NekoStack, any schema referenced by `s.lazy()` **must** have an `.id()`. This forces you to name your recursive structures, ensuring the generated JSON Schema can emit valid `$ref` URLs rather than creating infinite expansion loops.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Next Steps
|
|
147
|
+
|
|
148
|
+
1. Read the [Issue Codes Catalog](./ISSUE_CODES.md) to see how NekoStack normalizes errors.
|
|
149
|
+
2. See [EXAMPLES.md](./EXAMPLES.md) for advanced compositions (`pick`, `omit`, `extend`).
|
|
150
|
+
3. Ready for the next level? Learn how to safely evolve your data with [Migrations](./MIGRATIONS.md).
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# OpenAPI 3.1 Mapping Contract
|
|
2
|
+
|
|
3
|
+
> How `generateOpenApiSchemaComponent` translates a `SchemaNode` into an **OpenAPI 3.1 Schema Object** that lives under `components.schemas.<Name>`. This file documents the **deltas from JSON Schema** — the rest of the mapping is shared with [`JSON_SCHEMA_MAPPING.md`](./JSON_SCHEMA_MAPPING.md) via the internal [`src/generators/schema-fragment.ts`](../src/generators/schema-fragment.ts) module.
|
|
4
|
+
|
|
5
|
+
## Why this doc is short
|
|
6
|
+
|
|
7
|
+
OpenAPI 3.1 explicitly aligns its Schema Object with **JSON Schema draft 2020-12** — the spec calls it a superset. Everything in `JSON_SCHEMA_MAPPING.md` (absence semantics, object policy, refinement mapping, throw contract, `x-nekostack-*` extensions) applies unchanged to OpenAPI 3.1 component schemas because the same shared fragment emitter produces them.
|
|
8
|
+
|
|
9
|
+
This doc only records what's **different**.
|
|
10
|
+
|
|
11
|
+
## Output unit
|
|
12
|
+
|
|
13
|
+
`generateOpenApiSchemaComponent(node)` returns a **single component schema** — the value that would live at `components.schemas.<Name>` in a full OpenAPI document. Composing it into paths / operations / responses / requestBodies / etc. is the consumer's job (or `@nekostack/api`'s when that package exists).
|
|
14
|
+
|
|
15
|
+
**Not in scope for v0.4:**
|
|
16
|
+
- Full OpenAPI documents
|
|
17
|
+
- Other component types (responses, parameters, requestBodies, examples, headers, securitySchemes, links, callbacks, pathItems)
|
|
18
|
+
- OpenAPI 3.0 target (3.0 uses `nullable: true`; 3.1 uses `type: ["x", "null"]` — v0.4 ships 3.1 only)
|
|
19
|
+
|
|
20
|
+
## Differences from JSON Schema output
|
|
21
|
+
|
|
22
|
+
| | JSON Schema (`generateJsonSchema`) | OpenAPI 3.1 component (`generateOpenApiSchemaComponent`) |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| `$schema` | `"https://json-schema.org/draft/2020-12/schema"` | **omitted** — OpenAPI 3.1 documents declare the dialect once at the document root via `jsonSchemaDialect`; components inherit it |
|
|
25
|
+
| `$id` | URN by default; URL via `options.idBase` | **omitted** — component identity is the position in the document (`#/components/schemas/<Name>`) |
|
|
26
|
+
| `x-nekostack.generator` | `"jsonSchema"` | `"openApi"` |
|
|
27
|
+
| `irHash` in provenance | sha256 of canonical IR | **identical** (same node → same hash, proven by test) |
|
|
28
|
+
| Output bytes | canonical JSON + trailing newline | canonical JSON + trailing newline (same convention) |
|
|
29
|
+
|
|
30
|
+
That's it. Everything else — `type`, `properties`, `required`, `additionalProperties`, `default`, `pattern`, `format`, `enum`, `const`, `description`, `deprecated`, the `x-nekostack-strip` extension, the `x-nekostack-default-applied-by` extension, all portable refinement keyword mappings — is shared via `emitSchemaFragment` and produces the same bytes.
|
|
31
|
+
|
|
32
|
+
## Why the shared fragment
|
|
33
|
+
|
|
34
|
+
Decision #3 of the v0.4 plan flipped the original "parallel implementation" direction to a shared `emitSchemaFragment`. Rationale: duplicating the v0.3 mapping in a separate `openapi.ts` would create a drift vector. Bug fixes would have to land twice; behavior could silently diverge across the two generators despite OpenAPI 3.1 explicitly being a superset of JSON Schema draft 2020-12.
|
|
35
|
+
|
|
36
|
+
The wrappers (`json-schema.ts`, `openapi.ts`) own only what genuinely differs: root document structure, `$schema` / `$id` decisions, and the `generator` field value in provenance. Anything related to IR translation lives in `schema-fragment.ts` and is exercised by both generators' test suites.
|
|
37
|
+
|
|
38
|
+
## Throw contract
|
|
39
|
+
|
|
40
|
+
Identical to JSON Schema (same `kind` values; the `generator` field flips to `"openApi"`):
|
|
41
|
+
|
|
42
|
+
| Case | `kind` | `generator` |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| IR kind `date` / `union` / `recursiveRef` / `transform` | the kind name | `"openApi"` |
|
|
45
|
+
| Runtime refinement | `"runtimeRefinement"` | `"openApi"` |
|
|
46
|
+
| `regex` with non-empty flags | `"regexFlags"` | `"openApi"` |
|
|
47
|
+
|
|
48
|
+
Tests assert on `code` / `kind` / `generator` per the v0.2 stable-error contract.
|
|
49
|
+
|
|
50
|
+
## Round-trip validation
|
|
51
|
+
|
|
52
|
+
[`../tests/generators/openapi-redocly.test.ts`](../tests/generators/openapi-redocly.test.ts) composes every emitted component schema into a synthetic OpenAPI 3.1 document and validates via `@redocly/openapi-core`. Catches spec/tooling issues that mere JSON validity wouldn't surface.
|
|
53
|
+
|
|
54
|
+
Per the v0.4 plan's fallback clause: if `@redocly/openapi-core`'s programmatic API turns out to be impractical, tests may switch to spawning the Redocly CLI instead. The validation requirement — compose into synthetic doc, assert clean — does not change.
|
|
55
|
+
|
|
56
|
+
The OpenAPI Specification is authoritative; Redocly is the actively-maintained validation tool we delegate to.
|
|
57
|
+
|
|
58
|
+
## When this doc gets longer
|
|
59
|
+
|
|
60
|
+
When v0.5+ adds OpenAPI-specific behaviors:
|
|
61
|
+
- `discriminator` keyword (needs union builders + a discriminator field selector).
|
|
62
|
+
- `example` / `externalDocs` / `xml` keywords (need IR-level support).
|
|
63
|
+
- OpenAPI 3.0 target as a generator option (if a real consumer needs it).
|
|
64
|
+
- Per-server / per-path schema variants.
|
|
65
|
+
|
|
66
|
+
Each gets a section here. Until then, the JSON_SCHEMA_MAPPING.md mapping table is the authoritative reference; this doc records only the table-of-deltas above.
|