@lovrozagar/honey 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1596 -6
- package/dist/accepts.d.ts +6 -0
- package/dist/accepts.d.ts.map +1 -0
- package/dist/body-limit.d.ts +22 -0
- package/dist/body-limit.d.ts.map +1 -0
- package/dist/build/index.d.ts +42 -0
- package/dist/build/index.d.ts.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/client/error.d.ts +14 -0
- package/dist/client/error.d.ts.map +1 -0
- package/dist/client/http.d.ts +88 -0
- package/dist/client/http.d.ts.map +1 -0
- package/dist/client/index.d.ts +22 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/sdk.d.ts +34 -0
- package/dist/client/sdk.d.ts.map +1 -0
- package/dist/client/sse.d.ts +10 -0
- package/dist/client/sse.d.ts.map +1 -0
- package/dist/client/types.d.ts +124 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/ws.d.ts +15 -0
- package/dist/client/ws.d.ts.map +1 -0
- package/dist/client-mcp/server.d.ts +10 -0
- package/dist/client-mcp/server.d.ts.map +1 -0
- package/dist/client-mcp/types.d.ts +16 -0
- package/dist/client-mcp/types.d.ts.map +1 -0
- package/dist/cloudflare/realtime.d.ts +30 -0
- package/dist/cloudflare/realtime.d.ts.map +1 -0
- package/dist/codegen-go-cli.d.ts +22 -0
- package/dist/codegen-go-cli.d.ts.map +1 -0
- package/dist/codegen-go.d.ts +29 -0
- package/dist/codegen-go.d.ts.map +1 -0
- package/dist/codegen-hash.d.ts +3 -0
- package/dist/codegen-hash.d.ts.map +1 -0
- package/dist/codegen-ir.d.ts +123 -0
- package/dist/codegen-ir.d.ts.map +1 -0
- package/dist/codegen-loaders.d.ts +12 -0
- package/dist/codegen-loaders.d.ts.map +1 -0
- package/dist/codegen-mcp.d.ts +13 -0
- package/dist/codegen-mcp.d.ts.map +1 -0
- package/dist/codegen-python.d.ts +21 -0
- package/dist/codegen-python.d.ts.map +1 -0
- package/dist/codegen-rust.d.ts +18 -0
- package/dist/codegen-rust.d.ts.map +1 -0
- package/dist/codegen-sanitize.d.ts +3 -0
- package/dist/codegen-sanitize.d.ts.map +1 -0
- package/dist/codegen-schema-naming.d.ts +22 -0
- package/dist/codegen-schema-naming.d.ts.map +1 -0
- package/dist/codegen.d.ts +178 -0
- package/dist/codegen.d.ts.map +1 -0
- package/dist/context.d.ts +56 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/cookie-sign.d.ts +3 -0
- package/dist/cookie-sign.d.ts.map +1 -0
- package/dist/cookie.d.ts +12 -0
- package/dist/cookie.d.ts.map +1 -0
- package/dist/cors.d.ts +13 -0
- package/dist/cors.d.ts.map +1 -0
- package/dist/crypto.d.ts +2 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/csrf.d.ts +9 -0
- package/dist/csrf.d.ts.map +1 -0
- package/dist/curl-logger.d.ts +33 -0
- package/dist/curl-logger.d.ts.map +1 -0
- package/dist/detect-runtime.d.ts +16 -0
- package/dist/detect-runtime.d.ts.map +1 -0
- package/dist/error.d.ts +21 -0
- package/dist/error.d.ts.map +1 -0
- package/dist/errors.d.ts +40 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/etag.d.ts +9 -0
- package/dist/etag.d.ts.map +1 -0
- package/dist/feature-load.d.ts +3 -0
- package/dist/feature-load.d.ts.map +1 -0
- package/dist/go-type-emitter.d.ts +33 -0
- package/dist/go-type-emitter.d.ts.map +1 -0
- package/dist/honey-response.d.ts +82 -0
- package/dist/honey-response.d.ts.map +1 -0
- package/dist/i18n-slot.d.ts +7 -0
- package/dist/i18n-slot.d.ts.map +1 -0
- package/dist/i18n.d.ts +26 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/index.d.ts +470 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/init.d.ts +7 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/input.d.ts +6 -0
- package/dist/input.d.ts.map +1 -0
- package/dist/ip-restrict.d.ts +12 -0
- package/dist/ip-restrict.d.ts.map +1 -0
- package/dist/logger.d.ts +45 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/meta-spec.d.ts +81 -0
- package/dist/meta-spec.d.ts.map +1 -0
- package/dist/middleware.d.ts +60 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/node-request.d.ts +59 -0
- package/dist/node-request.d.ts.map +1 -0
- package/dist/node.d.ts +13 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/openapi/register.d.ts +3 -0
- package/dist/openapi/register.d.ts.map +1 -0
- package/dist/openapi/scalar.d.ts +12 -0
- package/dist/openapi/scalar.d.ts.map +1 -0
- package/dist/openapi/spec-factory.d.ts +31 -0
- package/dist/openapi/spec-factory.d.ts.map +1 -0
- package/dist/openapi/spec.d.ts +19 -0
- package/dist/openapi/spec.d.ts.map +1 -0
- package/dist/openapi/swagger.d.ts +12 -0
- package/dist/openapi/swagger.d.ts.map +1 -0
- package/dist/plugin.d.ts +167 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/powered-by.d.ts +7 -0
- package/dist/powered-by.d.ts.map +1 -0
- package/dist/pretty-json.d.ts +10 -0
- package/dist/pretty-json.d.ts.map +1 -0
- package/dist/proxy.d.ts +51 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/python-type-emitter.d.ts +11 -0
- package/dist/python-type-emitter.d.ts.map +1 -0
- package/dist/realtime/buffer.d.ts +18 -0
- package/dist/realtime/buffer.d.ts.map +1 -0
- package/dist/realtime/bus.d.ts +11 -0
- package/dist/realtime/bus.d.ts.map +1 -0
- package/dist/realtime/longpoll.d.ts +23 -0
- package/dist/realtime/longpoll.d.ts.map +1 -0
- package/dist/realtime/protocol.d.ts +50 -0
- package/dist/realtime/protocol.d.ts.map +1 -0
- package/dist/realtime/route.d.ts +42 -0
- package/dist/realtime/route.d.ts.map +1 -0
- package/dist/realtime/runtime.d.ts +74 -0
- package/dist/realtime/runtime.d.ts.map +1 -0
- package/dist/request-id.d.ts +12 -0
- package/dist/request-id.d.ts.map +1 -0
- package/dist/request-to-curl.d.ts +9 -0
- package/dist/request-to-curl.d.ts.map +1 -0
- package/dist/response.d.ts +57 -0
- package/dist/response.d.ts.map +1 -0
- package/dist/rust-type-emitter.d.ts +32 -0
- package/dist/rust-type-emitter.d.ts.map +1 -0
- package/dist/secure-headers.d.ts +16 -0
- package/dist/secure-headers.d.ts.map +1 -0
- package/dist/serve-register.d.ts +2 -0
- package/dist/serve-register.d.ts.map +1 -0
- package/dist/serve-slot.d.ts +6 -0
- package/dist/serve-slot.d.ts.map +1 -0
- package/dist/serve.d.ts +21 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/server-timing.d.ts +9 -0
- package/dist/server-timing.d.ts.map +1 -0
- package/dist/static.d.ts +12 -0
- package/dist/static.d.ts.map +1 -0
- package/dist/telemetry/otel.d.ts +59 -0
- package/dist/telemetry/otel.d.ts.map +1 -0
- package/dist/testing.d.ts +24 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/timeout.d.ts +7 -0
- package/dist/timeout.d.ts.map +1 -0
- package/dist/tree.d.ts +90 -0
- package/dist/tree.d.ts.map +1 -0
- package/dist/ts-type-emitter.d.ts +4 -0
- package/dist/ts-type-emitter.d.ts.map +1 -0
- package/dist/type-emitter.d.ts +8 -0
- package/dist/type-emitter.d.ts.map +1 -0
- package/dist/type-extractor.d.ts +39 -0
- package/dist/type-extractor.d.ts.map +1 -0
- package/dist/types.d.ts +451 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/validation.d.ts +18 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/ws/bun.d.ts +28 -0
- package/dist/ws/bun.d.ts.map +1 -0
- package/dist/ws/cloudflare.d.ts +55 -0
- package/dist/ws/cloudflare.d.ts.map +1 -0
- package/dist/ws/deno.d.ts +13 -0
- package/dist/ws/deno.d.ts.map +1 -0
- package/dist/ws/node.d.ts +10 -0
- package/dist/ws/node.d.ts.map +1 -0
- package/dist/yaml.d.ts +4 -0
- package/dist/yaml.d.ts.map +1 -0
- package/docs/api-ref/go.md +82 -82
- package/docs/api-ref/python.md +82 -82
- package/docs/api-ref/rust.md +82 -82
- package/docs/api-ref/typescript.md +82 -82
- package/docs/invalidate-spec.md +13 -13
- package/docs/meta-spec.md +513 -0
- package/docs/packaging.md +85 -0
- package/docs/sdk.md +87 -73
- package/package.json +195 -51
- package/src/body-limit.ts +2 -1
- package/src/build/index.ts +2 -9
- package/src/cli.ts +2 -3
- package/src/client/http.ts +204 -223
- package/src/client/index.ts +6 -14
- package/src/client/sdk.ts +24 -17
- package/src/client/types.ts +4 -9
- package/src/client/ws.ts +3 -5
- package/src/client-mcp/server.ts +1 -4
- package/src/cloudflare/realtime.ts +8 -10
- package/src/codegen-go-cli.ts +74 -67
- package/src/codegen-go.ts +71 -51
- package/src/codegen-ir.ts +16 -27
- package/src/codegen-loaders.ts +5 -8
- package/src/codegen-mcp.ts +9 -27
- package/src/codegen-python.ts +47 -27
- package/src/codegen-rust.ts +110 -66
- package/src/codegen-sanitize.ts +1 -7
- package/src/codegen-schema-naming.ts +8 -2
- package/src/codegen.ts +299 -196
- package/src/context.ts +91 -95
- package/src/cookie-sign.ts +5 -13
- package/src/cookie.ts +1 -2
- package/src/cors.ts +4 -6
- package/src/crypto.ts +1 -4
- package/src/csrf.ts +3 -3
- package/src/curl-logger.ts +14 -32
- package/src/detect-runtime.ts +1 -3
- package/src/errors.ts +1 -3
- package/src/etag.ts +3 -4
- package/src/go-type-emitter.ts +45 -40
- package/src/honey-response.ts +1 -3
- package/src/i18n.ts +3 -12
- package/src/index.ts +1168 -1412
- package/src/ip-restrict.ts +2 -1
- package/src/logger.ts +13 -13
- package/src/meta-spec.ts +0 -0
- package/src/middleware.ts +111 -79
- package/src/node-request.ts +2 -1
- package/src/node.ts +9 -10
- package/src/openapi/register.ts +1 -2
- package/src/openapi/scalar.ts +9 -9
- package/src/openapi/spec-factory.ts +2 -2
- package/src/openapi/spec.ts +26 -19
- package/src/openapi/swagger.ts +9 -9
- package/src/plugin.ts +226 -256
- package/src/powered-by.ts +2 -1
- package/src/pretty-json.ts +2 -1
- package/src/proxy.ts +1 -3
- package/src/python-type-emitter.ts +37 -12
- package/src/realtime/route.ts +1 -1
- package/src/realtime/runtime.ts +9 -12
- package/src/request-id.ts +3 -4
- package/src/request-to-curl.ts +1 -4
- package/src/response.ts +150 -199
- package/src/rust-type-emitter.ts +66 -25
- package/src/secure-headers.ts +2 -1
- package/src/serve.ts +3 -12
- package/src/server-timing.ts +2 -1
- package/src/static.ts +3 -4
- package/src/telemetry/otel.ts +1 -6
- package/src/testing.ts +2 -11
- package/src/timeout.ts +2 -1
- package/src/tree.ts +12 -27
- package/src/ts-type-emitter.ts +71 -15
- package/src/type-emitter.ts +226 -237
- package/src/type-extractor.ts +260 -334
- package/src/types.ts +168 -68
- package/src/validation.ts +7 -30
- package/src/ws/bun.ts +2 -9
- package/src/ws/cloudflare.ts +13 -16
- package/src/ws/deno.ts +31 -40
- package/src/ws/node.ts +4 -19
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
# `.metaSpec()` — declarative control over what reaches the OpenAPI document
|
|
2
|
+
|
|
3
|
+
Status: implemented in `@lovrozagar/honey` (tree of record: `packages/core`).
|
|
4
|
+
|
|
5
|
+
## 1. The problem
|
|
6
|
+
|
|
7
|
+
Before this feature, `generateOpenApi` mapped route meta onto the operation object with a
|
|
8
|
+
hardcoded allowlist of eight fields:
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
summary, description, tags, deprecated, operationId, security, invalidate → x-invalidate, mcp → x-mcp
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Everything else on `.meta()` was **silently discarded**. That has three consequences:
|
|
15
|
+
|
|
16
|
+
1. **Facts the framework already knows never leave the process.** An app that classifies every
|
|
17
|
+
route by rate-limit category, permission set, or tenancy carries those facts on route meta and
|
|
18
|
+
the document never sees them. Downstream consumers (SDK codegen, gateways, contract testers such
|
|
19
|
+
as [`oat`](https://github.com/lovrozagar/oat)) then re-derive them by heuristic, or don't run the
|
|
20
|
+
check at all.
|
|
21
|
+
2. **Hiding is accidental, not deliberate.** Fields that _must not_ be published — a captcha
|
|
22
|
+
provider, a test-bypass surface, internal worker routing — were protected only because the
|
|
23
|
+
allowlist happened not to know about them. Nothing recorded the intent.
|
|
24
|
+
3. **Rot is silent.** Adding a field to the app's meta type produced no signal anywhere. The
|
|
25
|
+
default outcome of growth was "dropped".
|
|
26
|
+
|
|
27
|
+
`.metaSpec()` replaces the allowlist with a **declarative, total, per-document policy**.
|
|
28
|
+
|
|
29
|
+
## 2. Public API
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
honey<Env>()
|
|
33
|
+
.meta<HoneyMeta<AppRouteMeta>>()
|
|
34
|
+
.metaSpec({
|
|
35
|
+
strict: "error",
|
|
36
|
+
|
|
37
|
+
meta: {
|
|
38
|
+
permissions: "x-permissions",
|
|
39
|
+
rateLimit: { key: "x-rate-limit", map: (v) => ({ category: v, rps: RPS[v] }) },
|
|
40
|
+
summary: { key: "summary" },
|
|
41
|
+
worker: false,
|
|
42
|
+
captcha: false,
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
schema: {
|
|
46
|
+
entity: {
|
|
47
|
+
from: ["output"],
|
|
48
|
+
search: "deep",
|
|
49
|
+
expand: (e) => ({
|
|
50
|
+
"x-entity": e.table,
|
|
51
|
+
"x-generated": e.generated,
|
|
52
|
+
"x-immutable": e.immutable,
|
|
53
|
+
"x-soft-delete": e.softDelete ? { field: e.softDelete } : undefined,
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
profiles: {
|
|
59
|
+
// default-deny: a tag added tomorrow is absent here until someone opts it in
|
|
60
|
+
public: { include: ["x-entity", "x-query"] },
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
honey does not own the shape of `e` above. The descriptor is a contract between whatever stamps the
|
|
66
|
+
schema (an ORM, a DTO layer) and the app's policy block; honey only carries it. Agree those field
|
|
67
|
+
names once, on the publisher's side, before either end writes code against them.
|
|
68
|
+
|
|
69
|
+
### 2.1 Entry forms
|
|
70
|
+
|
|
71
|
+
A policy entry describes one **source key** (a meta key, or a key read off a schema) and what it
|
|
72
|
+
contributes to the operation object.
|
|
73
|
+
|
|
74
|
+
| Form | Meaning |
|
|
75
|
+
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
76
|
+
| `false` | **Hidden.** Never emitted, in any profile. Explicit and greppable. |
|
|
77
|
+
| `"x-foo"` / `"summary"` | **Verbatim.** The source value is written to that operation key unchanged. |
|
|
78
|
+
| `{ key, map?, schema?, on?, profiles? }` | **Single target.** `map` transforms the value; returning `undefined` omits it for that route. |
|
|
79
|
+
| `{ expand, schema?, on?, profiles? }` | **Fan-out.** Returns a record of operation keys → values; `undefined` values and an `undefined` return omit. |
|
|
80
|
+
|
|
81
|
+
Common options:
|
|
82
|
+
|
|
83
|
+
- `on?: "http" | "ws" | "both"` — which operation kinds the entry applies to. Default `"both"`.
|
|
84
|
+
- `profiles?: readonly string[]` — restrict the entry to named documents. Default: every profile.
|
|
85
|
+
- `schema?: StandardSchemaLike` — validates the produced value (see §6).
|
|
86
|
+
|
|
87
|
+
`map` / `expand` receive `(value, ctx)` where `ctx` is `{ method, path, meta, profile, source }`.
|
|
88
|
+
|
|
89
|
+
### 2.2 Target keys
|
|
90
|
+
|
|
91
|
+
An entry may target:
|
|
92
|
+
|
|
93
|
+
- any **extension** key matching `x-*`, or
|
|
94
|
+
- one of the **standard operation fields** honey allows a policy to set:
|
|
95
|
+
`summary`, `description`, `tags`, `deprecated`, `operationId`, `security`, `externalDocs`,
|
|
96
|
+
`servers`.
|
|
97
|
+
|
|
98
|
+
`responses`, `parameters`, `requestBody` and `callbacks` are **reserved** — they are derived from
|
|
99
|
+
schemas and error keys, and a policy that wrote them would produce a document that contradicts the
|
|
100
|
+
route. Targeting them is a build error. Any other non-`x-` key is a build error too (it would be
|
|
101
|
+
dropped by spec-conformant readers, which is the failure mode this feature exists to remove).
|
|
102
|
+
|
|
103
|
+
### 2.3 Schema-derived facts
|
|
104
|
+
|
|
105
|
+
The `schema` section is keyed by **the entry's own name**. `read` says which schema-metadata key it
|
|
106
|
+
reads, defaulting to that name. Sources:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
"output" | "input.json" | "input.search" | "input.form" | "input.headers" | "input.cookies"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`from` defaults to all of them, in that order; the first source that carries the key wins.
|
|
113
|
+
`"output"` reads the JSON output schema of the lowest declared 2xx status.
|
|
114
|
+
|
|
115
|
+
`search` decides how deeply to look:
|
|
116
|
+
|
|
117
|
+
| `search` | Behavior |
|
|
118
|
+
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
119
|
+
| `"root"` (default) | the schema root, seeing through one level of `items` so a bare `array<Entity>` list output still finds the descriptor stamped on the item |
|
|
120
|
+
| `"deep"` | walks `properties`, `items` and composition members breadth-first (max depth 6) and takes the **shallowest** match — for envelope shapes like `{ articles: [Article], count, hasMore, nextCursor }` |
|
|
121
|
+
|
|
122
|
+
A deep search that finds two _different_ values at the same depth is a build error
|
|
123
|
+
(`AMBIGUOUS_SCHEMA_KEY`), not a guess. The same descriptor reached by several paths is fine.
|
|
124
|
+
|
|
125
|
+
Reading is vendor-neutral: honey converts the schema to JSON Schema (it already does, for the
|
|
126
|
+
request/response bodies) and reads app keys off the **root** of that JSON Schema. For Zod that is
|
|
127
|
+
exactly what `.meta({ … })` produces, so an ORM that stamps a descriptor onto the schema it already
|
|
128
|
+
uses to parse the request gets the extension for free — and the tag cannot drift from runtime
|
|
129
|
+
behavior, because both read the same object.
|
|
130
|
+
|
|
131
|
+
One schema key routinely fans out to several extensions, which is why `expand` exists.
|
|
132
|
+
|
|
133
|
+
#### One reserved key carrying a union
|
|
134
|
+
|
|
135
|
+
A publisher with more than one kind of fact does not want more than one collision surface, so the
|
|
136
|
+
mature shape is a single reserved key holding a discriminated union with a version field —
|
|
137
|
+
[comb](https://github.com/lovrozagar/comb) stamps `x-comb` with `{ v, kind: "entity" | "query", … }`.
|
|
138
|
+
The two members arrive on _different schemas of the same operation_: the entity descriptor on the
|
|
139
|
+
response, the query descriptor on the search input.
|
|
140
|
+
|
|
141
|
+
`read` plus `match` expresses that. Several entries read one key and are told apart by the union's
|
|
142
|
+
discriminant:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
schema: {
|
|
146
|
+
entityFacts: {
|
|
147
|
+
read: "x-comb",
|
|
148
|
+
match: { kind: "entity" },
|
|
149
|
+
version: { max: 1 },
|
|
150
|
+
from: ["output"],
|
|
151
|
+
search: "deep",
|
|
152
|
+
expand: (e) => ({
|
|
153
|
+
"x-entity": e.name,
|
|
154
|
+
"x-identity": e.identity,
|
|
155
|
+
"x-soft-delete": e.softDelete ?? undefined,
|
|
156
|
+
"x-tenant-column": e.tenantColumn ?? undefined,
|
|
157
|
+
}),
|
|
158
|
+
},
|
|
159
|
+
queryFacts: {
|
|
160
|
+
read: "x-comb",
|
|
161
|
+
match: { kind: "query" },
|
|
162
|
+
version: { max: 1 },
|
|
163
|
+
from: ["input.search"],
|
|
164
|
+
expand: (q) => ({
|
|
165
|
+
"x-query": { filter: q.filterable, sort: q.sortable, maxLimit: q.maxLimit },
|
|
166
|
+
"x-searchable": q.searchable ?? undefined,
|
|
167
|
+
}),
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Resolution rules:
|
|
173
|
+
|
|
174
|
+
- Sources are walked in `from` order. A descriptor that fails `match` **does not end the walk** —
|
|
175
|
+
an entity entry must be free to step past the query descriptor when both entries search every
|
|
176
|
+
source.
|
|
177
|
+
- If no source yields a match but a descriptor _was_ found under `read`, that is a build error
|
|
178
|
+
(`DESCRIPTOR_MISMATCH`). A stamped fact nobody claimed is a misconfigured policy, not an absent
|
|
179
|
+
fact, and silently emitting nothing is how the gaps this feature exists to close came about.
|
|
180
|
+
- `version` is checked **before** `match`, because a newer contract may have redefined the
|
|
181
|
+
discriminant itself. A descriptor newer than `max`, or carrying no integer version when a guard
|
|
182
|
+
is declared, fails the build (`UNSUPPORTED_VERSION`) rather than emitting a partly-understood tag.
|
|
183
|
+
|
|
184
|
+
honey does not own the version number — it is the publisher's contract, and `max` is the app's
|
|
185
|
+
statement of which contract its `expand` was written against. Bumping the publisher then becomes a
|
|
186
|
+
build failure with a name, instead of tags that quietly change shape.
|
|
187
|
+
|
|
188
|
+
The alternatives were an array of entries under one key, and per-source matches that each run
|
|
189
|
+
`expand`. The array makes the policy value irregular (`entry | entry[]`) and leaves the two
|
|
190
|
+
contributions unnamed, which costs diagnostics — `schema."x-comb"[0]` reads worse than
|
|
191
|
+
`schema."entityFacts"`. Per-source matching would force one `expand` to handle every member of the
|
|
192
|
+
union and to de-duplicate its own target keys, moving work into app code that the policy layer
|
|
193
|
+
already does. Naming entries keeps one entry to one contribution, which is what the rest of the
|
|
194
|
+
design assumes.
|
|
195
|
+
|
|
196
|
+
> **Deliberate limit.** Deriving operation metadata from _schemas and table definitions_ is safe:
|
|
197
|
+
> they are a separate, hand-reviewed layer, and a handler can still fail to honor them — which is
|
|
198
|
+
> precisely the finding a contract tester should report. Deriving it from _observed handler
|
|
199
|
+
> behavior_ is not safe: it makes the document a mirror of the implementation and the tester
|
|
200
|
+
> self-confirming. honey has no record-from-runtime mode, and if one is ever added its output must
|
|
201
|
+
> land in a committed, reviewable artifact rather than being regenerated at test time.
|
|
202
|
+
|
|
203
|
+
### 2.4 Profiles (multi-document)
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
profiles: {
|
|
207
|
+
public: { include: ["x-entity", "x-query"] }, // default-deny — preferred
|
|
208
|
+
partner: { include: ["x-entity", "x-query", "x-cost"] },
|
|
209
|
+
staging: { exclude: ["x-rate-limit"] }, // denylist — internal documents only
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
- Profile filters operate on **emitted keys**, not source keys.
|
|
214
|
+
- `include` is an allowlist of `x-*` keys (standard fields are always kept unless excluded);
|
|
215
|
+
`exclude` is a denylist; with both, `include` applies first. Both also filter `meta.extensions`,
|
|
216
|
+
so the escape hatch cannot route around a profile.
|
|
217
|
+
- The implicit profile `"default"` filters nothing.
|
|
218
|
+
|
|
219
|
+
**Use `include` for a public document.** `exclude` is a denylist: a tag introduced next month is
|
|
220
|
+
published unless someone remembers to add it, and the failure mode is a leak. `include` is
|
|
221
|
+
default-deny, which is the posture a security boundary wants — the same posture `false` already
|
|
222
|
+
takes on individual keys. Reach for `exclude` only when the document is internal and the list of
|
|
223
|
+
things to withhold is genuinely shorter than the list to publish.
|
|
224
|
+
|
|
225
|
+
- A document selects a profile:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
codegen: {
|
|
229
|
+
openApi: [
|
|
230
|
+
{ path: "src/_gen/openapi.internal.json", title: "…", version: "1.0.0" },
|
|
231
|
+
{ path: "src/_gen/openapi.public.json", title: "…", version: "1.0.0", profile: "public" },
|
|
232
|
+
],
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`generateOpenApi(app, { …, profile: "public" })` is the programmatic form.
|
|
237
|
+
|
|
238
|
+
**Why document-level filtering rather than per-entry visibility as the primary model:** the
|
|
239
|
+
question a reviewer asks is "what is in the public document?" — that must be answerable by reading
|
|
240
|
+
one block, not by grepping forty entries for a visibility flag. Per-entry `profiles` remains
|
|
241
|
+
available for the inverse case ("this entry exists only for the internal document"), and `false`
|
|
242
|
+
covers "never, anywhere".
|
|
243
|
+
|
|
244
|
+
### 2.5 Escape hatch
|
|
245
|
+
|
|
246
|
+
`OpenApiMeta` now carries:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
extensions?: Record<`x-${string}`, unknown>
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
emitted verbatim, at the highest precedence, and still subject to profile filtering. Runtime
|
|
253
|
+
enforces the same contract the type states: only `x-*` keys are accepted, and anything else is an
|
|
254
|
+
`UNKNOWN_FIELD` build error. Because passthrough outranks every policy entry and is untyped inside
|
|
255
|
+
a `.meta()` call, letting it reach a standard field would let a stray key silently replace a
|
|
256
|
+
route's own `summary`. It exists so a
|
|
257
|
+
consumer that gains a tag before honey's policy is updated is not blocked. It is deliberately
|
|
258
|
+
secondary: raw passthrough spreads stringly-typed keys across call sites, has no public/private
|
|
259
|
+
separation, and fails on nothing.
|
|
260
|
+
|
|
261
|
+
## 2.6 Middleware-contributed meta
|
|
262
|
+
|
|
263
|
+
A fact enforced by middleware should not be retyped on every route it protects. Middleware can
|
|
264
|
+
contribute meta to every route that mounts it:
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
export const shard = createMiddleware(fn, { meta: { tenant: "project_id" } })
|
|
268
|
+
// or: defineMiddleware({ fn, errors: […], meta: { tenant: "project_id" } })
|
|
269
|
+
|
|
270
|
+
app.use(shard) // every route on the chain
|
|
271
|
+
app.use("/orgs", shard) // every route under a prefix
|
|
272
|
+
app.get("/x").use(shard).handler(…) // one route
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
One line, every route the middleware covers, and the tag cannot disagree with the code that
|
|
276
|
+
enforces it — a hand-written `tenant` on 104 routes can, and a wrong one is worse than a missing
|
|
277
|
+
one, because it makes a consumer confident about the wrong parameter.
|
|
278
|
+
|
|
279
|
+
Rules:
|
|
280
|
+
|
|
281
|
+
- **Explicit always wins.** Chain `.meta()` and route `.meta()` both outrank a contributed value.
|
|
282
|
+
Among middleware, later mount order wins.
|
|
283
|
+
- **Contributed meta is real meta.** It lands in the route's `mt`, so it appears on `ctx.meta`, in
|
|
284
|
+
the manifest, and — critically — it is subject to the policy. A middleware cannot smuggle an
|
|
285
|
+
untagged fact into the document: with `strict: "error"`, contributing `sla` with no policy entry
|
|
286
|
+
fails the build exactly as writing it on a route would.
|
|
287
|
+
- **A scoped middleware registered after its routes still reaches them.** `.use("/prefix", mw)`
|
|
288
|
+
back-fills already-registered handlers, mirroring what honey already does for scoped error keys.
|
|
289
|
+
A tag missing where enforcement happens is the failure direction that matters.
|
|
290
|
+
- **`internal` may not be contributed.** It controls whether a route appears in generated artifacts
|
|
291
|
+
at all; a middleware that removed 104 routes from the document would be invisible in both the
|
|
292
|
+
route and the middleware. `createMiddleware` throws.
|
|
293
|
+
|
|
294
|
+
### Why it is resolved at registration, not per request
|
|
295
|
+
|
|
296
|
+
The precompiled route tree bakes `mt` as a JSON literal (`mw` is never serialized), and
|
|
297
|
+
`.routeTree()` patch mode deliberately does not overwrite `mt` — the baked value is what production
|
|
298
|
+
serves. Contributed meta is therefore collected once, when the route registers, so the JIT and
|
|
299
|
+
precompiled paths cannot disagree. A lazily-derived value would produce one answer in development
|
|
300
|
+
and a different, stale one in production — the exact class of divergence this feature exists to
|
|
301
|
+
remove. The usual contract applies: change what a middleware contributes, regenerate.
|
|
302
|
+
|
|
303
|
+
### Two things to put in a release note
|
|
304
|
+
|
|
305
|
+
- **Adding a middleware can break a build.** Contributed meta is subject to totality, so mounting a
|
|
306
|
+
middleware that contributes `tenant` fails the build of any app whose policy has no `tenant`
|
|
307
|
+
entry — an app that never touched its own meta type. That is the correct trade (explicit beats
|
|
308
|
+
silent) but it is a surprising blast radius for what looks like a local change.
|
|
309
|
+
- The tap interaction below.
|
|
310
|
+
|
|
311
|
+
### One runtime interaction to know about
|
|
312
|
+
|
|
313
|
+
Meta-driven taps fire for every registered tap key found on a route's meta. A middleware
|
|
314
|
+
contributing `tenant` will therefore start firing an `app.tap("tenant", …)` handler on every route
|
|
315
|
+
it covers. That is coherent — "run this for every tenant-scoped route" is what meta-driven taps are
|
|
316
|
+
for — but it is a behavior change triggered from a distance, so it is worth grepping for tap keys
|
|
317
|
+
that collide with meta keys a middleware contributes.
|
|
318
|
+
|
|
319
|
+
## 2.7 `undefined` omits, `null` is emitted
|
|
320
|
+
|
|
321
|
+
`map` returning `undefined`, an `undefined` value inside an `expand` record, and a source key absent
|
|
322
|
+
from meta all mean **omit**. `null` is a value and is **emitted verbatim**.
|
|
323
|
+
|
|
324
|
+
The difference is load-bearing whenever a publisher stamps facts it cannot always know. A schema
|
|
325
|
+
stamped with `searchable: null` or `tenantColumn: null` means _"I don't know"_, but passing that
|
|
326
|
+
straight through publishes `"x-searchable": null`, which a consumer reads as _"nothing is
|
|
327
|
+
searchable"_ — a confident wrong answer, worse than silence. Normalize in the policy:
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
expand: (e) => ({
|
|
331
|
+
"x-entity": e.table,
|
|
332
|
+
"x-searchable": e.searchable ?? undefined, // unknown → key absent
|
|
333
|
+
"x-tenant": e.tenantColumn ? { column: e.tenantColumn } : undefined,
|
|
334
|
+
})
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
honey does not collapse `null` to omitted for you, because "explicitly nothing" is a legitimate
|
|
338
|
+
statement that some consumers want (`"x-soft-delete": null` = _this entity definitively has no soft
|
|
339
|
+
delete_, as distinct from _unknown_). The framework carries what the policy produces; deciding which
|
|
340
|
+
of the two you mean is the policy's job.
|
|
341
|
+
|
|
342
|
+
This composes with precedence rather than fighting it. A publisher that stamps nothing for a fact
|
|
343
|
+
only the routing layer knows leaves the key absent at rank 1; a route-meta or middleware-contributed
|
|
344
|
+
value at rank 2 fills it wherever it exists, and where it does not, the key is simply missing —
|
|
345
|
+
which is the honest answer.
|
|
346
|
+
|
|
347
|
+
## 3. Precedence and merge rules
|
|
348
|
+
|
|
349
|
+
Contributions are collected per operation and resolved by **rank**, highest wins:
|
|
350
|
+
|
|
351
|
+
| Rank | Source |
|
|
352
|
+
| ---- | ------------------------------------------------------------------------------ |
|
|
353
|
+
| 3 | `meta.extensions` passthrough |
|
|
354
|
+
| 2 | route meta entries — `spec.meta` **and** the built-in default policy, one tier |
|
|
355
|
+
| 1 | schema-derived entries (`spec.schema`) |
|
|
356
|
+
|
|
357
|
+
Rules, stated once:
|
|
358
|
+
|
|
359
|
+
1. **Route meta beats schema-derived.** The built-in mappings are route meta too, so a route's
|
|
360
|
+
`summary` is never overwritten by a schema-derived entry. An app entry for a built-in key
|
|
361
|
+
replaces that built-in outright (same source key, so no clash).
|
|
362
|
+
2. **Chain meta vs route meta is unchanged**: `.meta()` on a chain is merged into each route's meta
|
|
363
|
+
at registration; route meta wins. The policy layer sees the already-merged object.
|
|
364
|
+
3. **Two entries of the same rank writing the same operation key is a build error**
|
|
365
|
+
(`DUPLICATE_TARGET`). Within-rank shadowing is always a mistake, never a feature.
|
|
366
|
+
4. **Key order in the emitted document is the order of first contribution**: built-ins, then app
|
|
367
|
+
meta, then schema-derived, then `extensions`. Precedence is resolved independently of write
|
|
368
|
+
order, so a higher-ranked contribution overwrites a value in place and never reorders the JSON.
|
|
369
|
+
5. **Aliasing a standard field from a second meta key requires hiding the built-in.**
|
|
370
|
+
`blurb: { key: "summary" }` alongside the built-in `summary` entry is a `DUPLICATE_TARGET` error;
|
|
371
|
+
write `summary: false` to say which one owns the field.
|
|
372
|
+
6. `undefined` from `map`, an `undefined` value inside an `expand` record, and a source key absent
|
|
373
|
+
from meta all mean _omit_, silently. This is the sanctioned way to write a conditional entry.
|
|
374
|
+
|
|
375
|
+
## 4. Totality
|
|
376
|
+
|
|
377
|
+
**A key of the app's meta type with no policy entry is an error, not a silent drop.** This single
|
|
378
|
+
rule is what stops the rot: adding a field to the meta type forces an emit-or-hide decision.
|
|
379
|
+
|
|
380
|
+
Enforced at two levels:
|
|
381
|
+
|
|
382
|
+
- **Type level.** The `meta` section is a mapped type over the app's meta type with `-?`, minus the
|
|
383
|
+
built-in `DefaultMeta` keys (which ship with default entries and may be _optionally_ overridden).
|
|
384
|
+
Omitting an app key is a TypeScript error at the `.metaSpec()` call site.
|
|
385
|
+
- **Codegen level.** Any key observed on a route's meta with no entry is reported per `strict`.
|
|
386
|
+
This catches JavaScript consumers, dynamically-assigned meta, and meta from mounted sub-apps whose
|
|
387
|
+
types were erased.
|
|
388
|
+
|
|
389
|
+
`strict` values: `"error"` (default when a `metaSpec` is declared), `"warn"`, `"off"`.
|
|
390
|
+
|
|
391
|
+
**Known limit:** a _misspelled_ policy key is not rejected at the call site. `.metaSpec()` takes a
|
|
392
|
+
generic `TSpec extends HoneyMetaSpec<TMeta>` — required to keep `Honey` from becoming invariant in
|
|
393
|
+
`TMeta`, which would break assignability across the whole builder — and TypeScript does not
|
|
394
|
+
excess-property-check a literal inferred as a generic. The typo is still caught, from the other
|
|
395
|
+
side: the real key it was meant to name now has no entry, so `MISSING_ENTRY` fires for it. The
|
|
396
|
+
misspelled entry itself is inert.
|
|
397
|
+
|
|
398
|
+
Apps that never call `.metaSpec()` run with `strict: "off"` and the built-in policy only — see §7.
|
|
399
|
+
|
|
400
|
+
## 5. Error taxonomy
|
|
401
|
+
|
|
402
|
+
All diagnostics are collected across the whole document and thrown as one aggregated
|
|
403
|
+
`Error` (first 20 listed, with a count), because failing on the first of 250 operations makes
|
|
404
|
+
migration miserable.
|
|
405
|
+
|
|
406
|
+
| Code | Level | When | Why this level |
|
|
407
|
+
| ---------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
|
408
|
+
| `MISSING_ENTRY` | error (`strict:"error"`) / warn / silent | a meta key on some route has no policy entry | the rot-stopper; downgradable during migration |
|
|
409
|
+
| `RESERVED_FIELD` | error | entry targets `responses` / `parameters` / `requestBody` / `callbacks` | would contradict schema-derived content |
|
|
410
|
+
| `UNKNOWN_FIELD` | error | entry targets a non-`x-`, non-allowlisted field | silently ignored by readers |
|
|
411
|
+
| `DUPLICATE_TARGET` | error | two same-rank entries write one key | ambiguous, order-dependent output |
|
|
412
|
+
| `INVALID_VALUE` | error | entry `schema` rejects the produced value | a malformed tag is indistinguishable from a missing one downstream |
|
|
413
|
+
| `ASYNC_VALIDATION` | error | entry `schema` validates asynchronously | codegen value resolution is synchronous |
|
|
414
|
+
| `MAP_THREW` | error | `map` / `expand` threw | policy bugs must not produce a half-populated document |
|
|
415
|
+
| `UNKNOWN_PROFILE` | error | a document requests a profile that is not declared | almost always a typo that would silently emit everything |
|
|
416
|
+
| `AMBIGUOUS_SCHEMA_KEY` | error | a `search: "deep"` entry matched two different values at one depth | guessing which entity a route serves is how a tester gets told the wrong thing |
|
|
417
|
+
| `DESCRIPTOR_MISMATCH` | error | a descriptor was found under `read` but satisfied no entry's `match` | a stamped fact nobody claimed is a misconfigured policy, not an absent fact |
|
|
418
|
+
| `UNSUPPORTED_VERSION` | error | a descriptor is newer than the entry's `version.max`, or carries no integer version | a partly-understood tag is worse than none — the consumer cannot tell it is partial |
|
|
419
|
+
| omitted value | silent | `map` returned `undefined`, or the meta key is absent | the sanctioned conditional |
|
|
420
|
+
|
|
421
|
+
Every message carries `method`, `path`, source key and target key.
|
|
422
|
+
|
|
423
|
+
## 6. Validating what is emitted
|
|
424
|
+
|
|
425
|
+
```ts
|
|
426
|
+
rateLimit: {
|
|
427
|
+
key: "x-rate-limit",
|
|
428
|
+
map: (v) => ({ category: v, rps: RPS[v] }),
|
|
429
|
+
schema: z.object({ category: z.string(), rps: z.number().int().positive() }),
|
|
430
|
+
}
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
The schema validates the **produced** value (for `expand`, the produced record). It runs at codegen
|
|
434
|
+
time only, synchronously, through Standard Schema — so any of honey's supported validators works.
|
|
435
|
+
A failure is a build error, not a warning: shipping a tag that a consumer silently ignores is
|
|
436
|
+
worse than not shipping it, because it looks like coverage.
|
|
437
|
+
|
|
438
|
+
## 7. Default policy and the compatibility argument
|
|
439
|
+
|
|
440
|
+
When an app declares no `metaSpec`, codegen runs the **built-in policy**, which is the previous
|
|
441
|
+
eight mappings, unchanged, with the same truthiness gates and the same emit order:
|
|
442
|
+
|
|
443
|
+
| Meta key | Target | Gate | HTTP | WS |
|
|
444
|
+
| ------------- | ---------------------------- | ---------------- | ---- | --- |
|
|
445
|
+
| `summary` | `summary` | truthy | ✓ | ✓ |
|
|
446
|
+
| `description` | `description` | truthy | ✓ | ✓ |
|
|
447
|
+
| `tags` | `tags` (string → `[string]`) | truthy | ✓ | ✓ |
|
|
448
|
+
| `deprecated` | `deprecated` | truthy | ✓ | — |
|
|
449
|
+
| `operationId` | `operationId` | truthy | ✓ | ✓ |
|
|
450
|
+
| `security` | `security` (normalized) | truthy | ✓ | — |
|
|
451
|
+
| `invalidate` | `x-invalidate` | array, non-empty | ✓ | — |
|
|
452
|
+
| `mcp` | `x-mcp` | `=== true` | ✓ | — |
|
|
453
|
+
| `internal` | _hidden_ | — | — | — |
|
|
454
|
+
| `extensions` | verbatim passthrough | — | ✓ | ✓ |
|
|
455
|
+
|
|
456
|
+
The WS column reproduces the previous WS mapping exactly (it was a strict subset). App entries
|
|
457
|
+
default to `on: "both"`; the asymmetry is preserved only for the built-ins.
|
|
458
|
+
|
|
459
|
+
Because the built-ins are compiled first and written first, and because precedence is resolved
|
|
460
|
+
independently of write order, **a no-policy app produces a byte-identical document**. There is a
|
|
461
|
+
test that asserts exactly this against the pre-change generator output.
|
|
462
|
+
|
|
463
|
+
## 8. Migration path (≈250 operations, 9-field meta type)
|
|
464
|
+
|
|
465
|
+
Mechanical, and reversible at every step.
|
|
466
|
+
|
|
467
|
+
1. **Add `.metaSpec({ strict: "warn", meta: {} })`.** Generate. Every dropped key is now listed,
|
|
468
|
+
once, with the routes that carry it. Nothing about the document changed.
|
|
469
|
+
2. **Triage the list into three buckets**: emit verbatim, emit transformed, hide. Write the entries.
|
|
470
|
+
`false` for anything internal — `worker`, `captcha`, `proxyTimeout` — so the intent is recorded
|
|
471
|
+
rather than implied.
|
|
472
|
+
3. **Add the public profile** with `exclude` for the tags that must not be published, and add the
|
|
473
|
+
second `codegen.openApi` entry. Diff the two documents; the diff _is_ the review.
|
|
474
|
+
4. **Flip `strict` to `"error"`.** The mapped type now also fails the typecheck if a tenth meta
|
|
475
|
+
field appears without a decision.
|
|
476
|
+
5. **Move ORM-derived facts to the `schema` section** last, once the meta-driven tags are stable.
|
|
477
|
+
This is the step that removes per-route annotation: the descriptor the ORM already stamps on the
|
|
478
|
+
schema fans out to `x-entity` / `x-generated` / `x-immutable` / `x-soft-delete` with no route
|
|
479
|
+
edits at all.
|
|
480
|
+
|
|
481
|
+
Steps 1–2 are per-meta-key (nine edits). Step 5 is per-entity, not per-route.
|
|
482
|
+
|
|
483
|
+
## 9. Trade-offs rejected
|
|
484
|
+
|
|
485
|
+
- **Flat spec object (`.metaSpec({ permissions: "x-permissions" })`) as the only form.** Reads well
|
|
486
|
+
until profiles, schema sources and `strict` need a home, and then every one of them collides with
|
|
487
|
+
a possible meta key name. Sections cost one nesting level and buy an unambiguous namespace.
|
|
488
|
+
- **Per-entry visibility (`visible: ["internal"]`) as the primary profile model.** Rejected as the
|
|
489
|
+
primary: it distributes the answer to "what does the public document contain" across the whole
|
|
490
|
+
policy. Kept as a secondary `profiles` option on entries.
|
|
491
|
+
- **Post-hoc stripping only** (the existing `sanitize.stripXExtensions`). It works, but it is a
|
|
492
|
+
denylist applied after the fact: a tag added later is published by default, and the failure mode
|
|
493
|
+
is a leak. Kept for spec-level surgery; not the mechanism for public/private separation.
|
|
494
|
+
- **A honey-side vocabulary of blessed tags** (`x-entity`, `x-tenant`, …). Rejected outright:
|
|
495
|
+
binding a framework to one consumer's tag set is how vendor lock-in starts. honey controls
|
|
496
|
+
_emission_; the vocabulary is the app's.
|
|
497
|
+
- **Deriving policy from a config file rather than the app builder.** The policy must be typed
|
|
498
|
+
against the app's meta type, which only exists in the app's own module graph.
|
|
499
|
+
- **Runtime enforcement.** Everything here is codegen-time. `.metaSpec()` stores one object on the
|
|
500
|
+
route graph and is never consulted on a request.
|
|
501
|
+
|
|
502
|
+
## 10. Open questions
|
|
503
|
+
|
|
504
|
+
- **Chain-scoped policies.** `.metaSpec()` is app-global (stored on the shared route graph, merged
|
|
505
|
+
when sub-apps are mounted, parent wins). Whether a mounted sub-app should be able to _override_ a
|
|
506
|
+
parent entry rather than only fill gaps is unresolved; today it fills gaps.
|
|
507
|
+
- **Profile inheritance.** Profiles are flat. If real apps grow four or five documents, `extends`
|
|
508
|
+
will be wanted.
|
|
509
|
+
- **Fan-out from a schema key into `parameters`.** `x-query` describes sortable/filterable columns
|
|
510
|
+
and duplicates information that could, in principle, be emitted as real query parameters. Both are
|
|
511
|
+
useful to different consumers; honey emits the extension and leaves parameters to the schema.
|
|
512
|
+
- **Component-level policy.** Nothing yet maps schema meta onto `components.schemas` entries, only
|
|
513
|
+
onto operations.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Packaging — why honey ships source _and_ declarations
|
|
2
|
+
|
|
3
|
+
## The problem
|
|
4
|
+
|
|
5
|
+
honey shipped raw TypeScript: `"files": ["docs", "src"]`, and every `exports` subpath pointed at
|
|
6
|
+
`./src/*.ts`. That is pleasant for us and hostile to a consumer, because **a package that ships
|
|
7
|
+
`.ts` inherits the consumer's compiler flags**. There is no per-directory suppression for `.ts`
|
|
8
|
+
under `node_modules`, and `skipLibCheck` does not help — it covers `.d.ts` only.
|
|
9
|
+
|
|
10
|
+
Measured against the real published tarball, from a fixture with every plausible strictness flag on:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
522 diagnostics originate inside @lovrozagar/honey
|
|
14
|
+
|
|
15
|
+
358 TS4111 Property 'x' comes from an index signature, must be accessed with ['x']
|
|
16
|
+
81 TS5097 An import path can only end with '.ts' when allowImportingTsExtensions is enabled
|
|
17
|
+
27 TS18048 'x' is possibly 'undefined'
|
|
18
|
+
22 TS2379 argument not assignable under exactOptionalPropertyTypes
|
|
19
|
+
…
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
A downstream app reported 482 under its own flag set. Same wall, different height.
|
|
23
|
+
|
|
24
|
+
## Why "just fix the source" was not enough
|
|
25
|
+
|
|
26
|
+
The obvious answer is to make our source satisfy those flags — `@lovrozagar/comb` ships raw `src`
|
|
27
|
+
and produces zero errors in the same app, so it is demonstrably possible. Two things ruled it out:
|
|
28
|
+
|
|
29
|
+
1. **TS5097 is not a style problem.** 81 of the diagnostics say the consumer must enable
|
|
30
|
+
`allowImportingTsExtensions`, which in turn requires `noEmit` or `emitDeclarationOnly` — we would
|
|
31
|
+
be dictating a consumer's build configuration. No amount of tidying our own source removes that;
|
|
32
|
+
it is inherent to importing `.ts` paths with extensions.
|
|
33
|
+
2. **The flag set is unbounded.** Fixing 522 errors buys immunity to _today's_ flags. Tomorrow a
|
|
34
|
+
consumer enables something that does not exist yet, and we are back here. `skipLibCheck` over
|
|
35
|
+
`.d.ts` is a real, permanent boundary; matching an open set of lint flags is a treadmill.
|
|
36
|
+
|
|
37
|
+
## What we do instead
|
|
38
|
+
|
|
39
|
+
Ship both, and split the conditions:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
"exports": {
|
|
43
|
+
".": { "types": "./dist/index.d.ts", "default": "./src/index.ts" }
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- **TypeScript** resolves `types` → generated `.d.ts`, which `skipLibCheck` covers. The consumer
|
|
48
|
+
never type-checks our source, so our lint posture is ours alone.
|
|
49
|
+
- **Bundlers and runtimes** resolve `default` → `./src/*.ts`, unchanged. Workers bundling, Vite,
|
|
50
|
+
and the precompiled-tree path all behave exactly as before.
|
|
51
|
+
- **`declarationMap` is emitted and `src` still ships**, so go-to-definition lands in real source
|
|
52
|
+
rather than a generated stub. The debugging story that made source-shipping attractive survives.
|
|
53
|
+
|
|
54
|
+
`prepack` builds `dist` (`tsc -p tsconfig.dts.json`). npm runs `prepack` for both `npm pack` and
|
|
55
|
+
`npm publish`, and the release workflow publishes from `packages/core`, so a tarball physically
|
|
56
|
+
cannot ship with a stale or missing `dist`.
|
|
57
|
+
|
|
58
|
+
## The trade-off, stated
|
|
59
|
+
|
|
60
|
+
We pay: a build step, ~870KB of declarations in the tarball, and one more artifact that must stay in
|
|
61
|
+
sync (it is generated, so it does — but it is a step that can be forgotten, which is why it hangs off
|
|
62
|
+
`prepack` rather than a human).
|
|
63
|
+
|
|
64
|
+
We keep: source shipping, and with it Workers bundling with no `dist/*.js` indirection, TS-path
|
|
65
|
+
debugging, and the ability to read the real implementation from `node_modules`.
|
|
66
|
+
|
|
67
|
+
We buy: consumers are permanently decoupled from our compiler flags, and from any flag TypeScript
|
|
68
|
+
adds later.
|
|
69
|
+
|
|
70
|
+
**This generalizes.** Any package of ours that ships source has the same exposure. The rule: ship
|
|
71
|
+
source for the runtime condition, ship declarations for the `types` condition, and gate it with a
|
|
72
|
+
packaging test. `comb` passes today only because its source happens to satisfy two particular flags
|
|
73
|
+
— that is luck, not a boundary, and it does not cover TS5097 for a consumer who cannot enable
|
|
74
|
+
`allowImportingTsExtensions`.
|
|
75
|
+
|
|
76
|
+
## The regression test
|
|
77
|
+
|
|
78
|
+
`bun run test:packaging` (`e2e/run-strict-consumer.ts`) packs the tarball exactly as npm would,
|
|
79
|
+
installs it into a throwaway consumer, type-checks against `e2e/strict-consumer/` — a fixture with
|
|
80
|
+
`strict`, `exactOptionalPropertyTypes`, `noPropertyAccessFromIndexSignature`,
|
|
81
|
+
`noUncheckedIndexedAccess`, `verbatimModuleSyntax` and friends — and fails on any diagnostic whose
|
|
82
|
+
path is inside the package.
|
|
83
|
+
|
|
84
|
+
It runs as its own CI job. Every other tier compiles honey with honey's own tsconfig, so this is the
|
|
85
|
+
only place the consumer's view is visible at all.
|