ts-procedures 8.3.0 → 8.4.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/agent_config/claude-code/skills/ts-procedures/SKILL.md +26 -8
- package/agent_config/claude-code/skills/ts-procedures/templates/client.md +3 -3
- package/agent_config/claude-code/skills/ts-procedures/templates/hono.md +3 -3
- package/agent_config/claude-code/skills/ts-procedures/templates/procedure.md +3 -3
- package/agent_config/claude-code/skills/ts-procedures/templates/stream-procedure.md +3 -3
- package/build/client/call.js +1 -1
- package/build/client/call.js.map +1 -1
- package/build/client/index.d.ts +1 -1
- package/build/client/index.js +23 -1
- package/build/client/index.js.map +1 -1
- package/build/client/index.test.js +87 -0
- package/build/client/index.test.js.map +1 -1
- package/build/client/resolve-options.d.ts +5 -4
- package/build/client/resolve-options.js +18 -7
- package/build/client/resolve-options.js.map +1 -1
- package/build/client/resolve-options.test.js +53 -24
- package/build/client/resolve-options.test.js.map +1 -1
- package/build/client/stream.js +1 -1
- package/build/client/stream.js.map +1 -1
- package/build/client/types.d.ts +31 -3
- package/build/codegen/__fixtures__/make-envelope.d.ts +41 -0
- package/build/codegen/__fixtures__/make-envelope.js +38 -0
- package/build/codegen/__fixtures__/make-envelope.js.map +1 -0
- package/build/codegen/bin/cli.d.ts +11 -0
- package/build/codegen/bin/cli.js +30 -21
- package/build/codegen/bin/cli.js.map +1 -1
- package/build/codegen/bin/cli.test.js +36 -1
- package/build/codegen/bin/cli.test.js.map +1 -1
- package/build/codegen/bin/flag-specs.d.ts +10 -0
- package/build/codegen/bin/flag-specs.js +60 -0
- package/build/codegen/bin/flag-specs.js.map +1 -0
- package/build/codegen/bin/flag-specs.test.d.ts +1 -0
- package/build/codegen/bin/flag-specs.test.js +26 -0
- package/build/codegen/bin/flag-specs.test.js.map +1 -0
- package/build/codegen/collect-models.d.ts +37 -0
- package/build/codegen/collect-models.js +74 -0
- package/build/codegen/collect-models.js.map +1 -0
- package/build/codegen/collect-models.test.d.ts +1 -0
- package/build/codegen/collect-models.test.js +40 -0
- package/build/codegen/collect-models.test.js.map +1 -0
- package/build/codegen/emit-client-runtime.js +1 -0
- package/build/codegen/emit-client-runtime.js.map +1 -1
- package/build/codegen/emit-models.d.ts +26 -0
- package/build/codegen/emit-models.js +53 -0
- package/build/codegen/emit-models.js.map +1 -0
- package/build/codegen/emit-models.test.d.ts +1 -0
- package/build/codegen/emit-models.test.js +42 -0
- package/build/codegen/emit-models.test.js.map +1 -0
- package/build/codegen/emit-scope.d.ts +10 -0
- package/build/codegen/emit-scope.js +119 -34
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/emit-types.d.ts +26 -1
- package/build/codegen/emit-types.js +27 -5
- package/build/codegen/emit-types.js.map +1 -1
- package/build/codegen/index.d.ts +5 -0
- package/build/codegen/index.js +2 -0
- package/build/codegen/index.js.map +1 -1
- package/build/codegen/model-refs.d.ts +27 -0
- package/build/codegen/model-refs.js +49 -0
- package/build/codegen/model-refs.js.map +1 -0
- package/build/codegen/model-refs.test.d.ts +1 -0
- package/build/codegen/model-refs.test.js +33 -0
- package/build/codegen/model-refs.test.js.map +1 -0
- package/build/codegen/pipeline.d.ts +3 -0
- package/build/codegen/pipeline.js +3 -1
- package/build/codegen/pipeline.js.map +1 -1
- package/build/codegen/schema-walk.d.ts +13 -0
- package/build/codegen/schema-walk.js +26 -0
- package/build/codegen/schema-walk.js.map +1 -0
- package/build/codegen/schema-walk.test.d.ts +1 -0
- package/build/codegen/schema-walk.test.js +35 -0
- package/build/codegen/schema-walk.test.js.map +1 -0
- package/build/codegen/targets/_shared/target-run.d.ts +5 -0
- package/build/codegen/targets/ts/run.js +28 -1
- package/build/codegen/targets/ts/run.js.map +1 -1
- package/build/codegen/targets/ts/shared-models.test.d.ts +1 -0
- package/build/codegen/targets/ts/shared-models.test.js +258 -0
- package/build/codegen/targets/ts/shared-models.test.js.map +1 -0
- package/build/doc-envelope.d.ts +13 -0
- package/build/doc-envelope.js +23 -0
- package/build/doc-envelope.js.map +1 -0
- package/build/doc-envelope.test.d.ts +1 -0
- package/build/doc-envelope.test.js +31 -0
- package/build/doc-envelope.test.js.map +1 -0
- package/build/exports.d.ts +2 -0
- package/build/exports.js +1 -0
- package/build/exports.js.map +1 -1
- package/docs/client-and-codegen.md +101 -0
- package/docs/handoffs/ajsc-named-type-collision.md +134 -0
- package/docs/handoffs/ajsc-named-type-support.md +181 -0
- package/docs/superpowers/plans/2026-06-05-dx-feedback-round.md +1292 -0
- package/docs/superpowers/specs/2026-06-05-dx-feedback-round-design.md +285 -0
- package/package.json +2 -2
- package/src/client/call.ts +1 -1
- package/src/client/index.test.ts +98 -0
- package/src/client/index.ts +32 -1
- package/src/client/resolve-options.test.ts +73 -26
- package/src/client/resolve-options.ts +23 -9
- package/src/client/stream.ts +1 -1
- package/src/client/types.ts +34 -3
- package/src/codegen/__fixtures__/make-envelope.ts +89 -0
- package/src/codegen/bin/cli.test.ts +38 -1
- package/src/codegen/bin/cli.ts +33 -22
- package/src/codegen/bin/flag-specs.test.ts +27 -0
- package/src/codegen/bin/flag-specs.ts +69 -0
- package/src/codegen/collect-models.test.ts +46 -0
- package/src/codegen/collect-models.ts +108 -0
- package/src/codegen/emit-client-runtime.ts +1 -0
- package/src/codegen/emit-models.test.ts +48 -0
- package/src/codegen/emit-models.ts +63 -0
- package/src/codegen/emit-scope.ts +145 -33
- package/src/codegen/emit-types.ts +48 -7
- package/src/codegen/index.ts +7 -0
- package/src/codegen/model-refs.test.ts +37 -0
- package/src/codegen/model-refs.ts +57 -0
- package/src/codegen/pipeline.ts +6 -1
- package/src/codegen/schema-walk.test.ts +37 -0
- package/src/codegen/schema-walk.ts +23 -0
- package/src/codegen/targets/_shared/target-run.ts +5 -0
- package/src/codegen/targets/ts/run.ts +33 -0
- package/src/codegen/targets/ts/shared-models.test.ts +283 -0
- package/src/doc-envelope.test.ts +35 -0
- package/src/doc-envelope.ts +30 -0
- package/src/exports.ts +2 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
function coerceToEnvelope(source) {
|
|
4
|
+
if (typeof source.toDocEnvelope === 'function') {
|
|
5
|
+
return source.toDocEnvelope();
|
|
6
|
+
}
|
|
7
|
+
if (typeof source.toJSON === 'function') {
|
|
8
|
+
return source.toJSON();
|
|
9
|
+
}
|
|
10
|
+
return source;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Serializes a doc envelope to disk as pretty JSON so codegen can run offline
|
|
14
|
+
* via `--file <path>` without a running server. Accepts a plain `DocEnvelope`,
|
|
15
|
+
* a builder exposing `toDocEnvelope()`, or a `DocRegistry` exposing `toJSON()`.
|
|
16
|
+
* Parent directories are created as needed.
|
|
17
|
+
*/
|
|
18
|
+
export async function writeDocEnvelope(source, path) {
|
|
19
|
+
const envelope = coerceToEnvelope(source);
|
|
20
|
+
await mkdir(dirname(path), { recursive: true });
|
|
21
|
+
await writeFile(path, JSON.stringify(envelope, null, 2), 'utf8');
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=doc-envelope.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"doc-envelope.js","sourceRoot":"","sources":["../src/doc-envelope.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AACnD,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AAQnC,SAAS,gBAAgB,CAAC,MAAyB;IACjD,IAAI,OAAQ,MAAsC,CAAC,aAAa,KAAK,UAAU,EAAE,CAAC;QAChF,OAAQ,MAA2C,CAAC,aAAa,EAAE,CAAA;IACrE,CAAC;IACD,IAAI,OAAQ,MAA+B,CAAC,MAAM,KAAK,UAAU,EAAE,CAAC;QAClE,OAAQ,MAAoC,CAAC,MAAM,EAAE,CAAA;IACvD,CAAC;IACD,OAAO,MAAqB,CAAA;AAC9B,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CAAC,MAAyB,EAAE,IAAY;IAC5E,MAAM,QAAQ,GAAG,gBAAgB,CAAC,MAAM,CAAC,CAAA;IACzC,MAAM,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IAC/C,MAAM,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,MAAM,CAAC,CAAA;AAClE,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { readFile, rm, mkdtemp } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { writeDocEnvelope } from './doc-envelope.js';
|
|
6
|
+
const envelope = { basePath: '', headers: [], errors: [], routes: [] };
|
|
7
|
+
describe('writeDocEnvelope', () => {
|
|
8
|
+
let dir;
|
|
9
|
+
afterEach(async () => { if (dir)
|
|
10
|
+
await rm(dir, { recursive: true, force: true }); });
|
|
11
|
+
it('writes a plain DocEnvelope as pretty JSON', async () => {
|
|
12
|
+
dir = await mkdtemp(join(tmpdir(), 'tsp-'));
|
|
13
|
+
const out = join(dir, 'nested', 'docs.json');
|
|
14
|
+
await writeDocEnvelope(envelope, out);
|
|
15
|
+
const parsed = JSON.parse(await readFile(out, 'utf8'));
|
|
16
|
+
expect(parsed).toEqual(envelope);
|
|
17
|
+
});
|
|
18
|
+
it('accepts a builder-like object with toDocEnvelope()', async () => {
|
|
19
|
+
dir = await mkdtemp(join(tmpdir(), 'tsp-'));
|
|
20
|
+
const out = join(dir, 'docs.json');
|
|
21
|
+
await writeDocEnvelope({ toDocEnvelope: () => envelope }, out);
|
|
22
|
+
expect(JSON.parse(await readFile(out, 'utf8'))).toEqual(envelope);
|
|
23
|
+
});
|
|
24
|
+
it('accepts a DocRegistry-like object (toJSON)', async () => {
|
|
25
|
+
dir = await mkdtemp(join(tmpdir(), 'tsp-'));
|
|
26
|
+
const out = join(dir, 'docs.json');
|
|
27
|
+
await writeDocEnvelope({ toJSON: () => envelope }, out);
|
|
28
|
+
expect(JSON.parse(await readFile(out, 'utf8'))).toEqual(envelope);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
//# sourceMappingURL=doc-envelope.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"doc-envelope.test.js","sourceRoot":"","sources":["../src/doc-envelope.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAA;AACxD,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAA;AACxD,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;AAChC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAChC,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AAGpD,MAAM,QAAQ,GAAgB,EAAE,QAAQ,EAAE,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,CAAA;AAEnF,QAAQ,CAAC,kBAAkB,EAAE,GAAG,EAAE;IAChC,IAAI,GAAW,CAAA;IACf,SAAS,CAAC,KAAK,IAAI,EAAE,GAAG,IAAI,GAAG;QAAE,MAAM,EAAE,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAA,CAAC,CAAC,CAAC,CAAA;IAEnF,EAAE,CAAC,2CAA2C,EAAE,KAAK,IAAI,EAAE;QACzD,GAAG,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,MAAM,CAAC,CAAC,CAAA;QAC3C,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,QAAQ,EAAE,WAAW,CAAC,CAAA;QAC5C,MAAM,gBAAgB,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAA;QACrC,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC,CAAA;QACtD,MAAM,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAA;IAClC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,oDAAoD,EAAE,KAAK,IAAI,EAAE;QAClE,GAAG,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,MAAM,CAAC,CAAC,CAAA;QAC3C,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,WAAW,CAAC,CAAA;QAClC,MAAM,gBAAgB,CAAC,EAAE,aAAa,EAAE,GAAG,EAAE,CAAC,QAAQ,EAAE,EAAE,GAAG,CAAC,CAAA;QAC9D,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAA;IACnE,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,4CAA4C,EAAE,KAAK,IAAI,EAAE;QAC1D,GAAG,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,MAAM,CAAC,CAAC,CAAA;QAC3C,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,WAAW,CAAC,CAAA;QAClC,MAAM,gBAAgB,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,QAAQ,EAAE,EAAE,GAAG,CAAC,CAAA;QACvD,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAA;IACnE,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}
|
package/build/exports.d.ts
CHANGED
|
@@ -7,3 +7,5 @@ export * from './schema/resolve-schema-lib.js';
|
|
|
7
7
|
export * from './schema/types.js';
|
|
8
8
|
export type { HttpReturn } from './create-http.js';
|
|
9
9
|
export type { TCreateHttpConfig } from './types.js';
|
|
10
|
+
export { writeDocEnvelope, type DocEnvelopeSource } from './doc-envelope.js';
|
|
11
|
+
export type { DocEnvelope } from './implementations/types.js';
|
package/build/exports.js
CHANGED
package/build/exports.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"exports.js","sourceRoot":"","sources":["../src/exports.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAA;AAC1B,cAAc,aAAa,CAAA;AAC3B,cAAc,kBAAkB,CAAA;AAChC,cAAc,iCAAiC,CAAA;AAC/C,cAAc,oBAAoB,CAAA;AAClC,cAAc,gCAAgC,CAAA;AAC9C,cAAc,mBAAmB,CAAA"}
|
|
1
|
+
{"version":3,"file":"exports.js","sourceRoot":"","sources":["../src/exports.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAA;AAC1B,cAAc,aAAa,CAAA;AAC3B,cAAc,kBAAkB,CAAA;AAChC,cAAc,iCAAiC,CAAA;AAC/C,cAAc,oBAAoB,CAAA;AAClC,cAAc,gCAAgC,CAAA;AAC9C,cAAc,mBAAmB,CAAA;AAGjC,OAAO,EAAE,gBAAgB,EAA0B,MAAM,mBAAmB,CAAA"}
|
|
@@ -72,6 +72,32 @@ const api = createClient({
|
|
|
72
72
|
})
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
+
## Offline codegen (no running server)
|
|
76
|
+
|
|
77
|
+
You don't need a live HTTP server to generate the client. Serialize the DocEnvelope to disk with `writeDocEnvelope`, then point codegen at the file with `--file`.
|
|
78
|
+
|
|
79
|
+
**Step 1 — Emit the envelope to a file:**
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
// scripts/emit-docs.ts
|
|
83
|
+
import { writeDocEnvelope } from 'ts-procedures'
|
|
84
|
+
import { buildApp } from '../src/app.js' // builds your HonoAppBuilder / DocRegistry
|
|
85
|
+
|
|
86
|
+
const builder = buildApp()
|
|
87
|
+
await writeDocEnvelope(builder, 'docs.json')
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`writeDocEnvelope` accepts a built `HonoAppBuilder` (via `toDocEnvelope()`), a `DocRegistry` (via `toJSON()`), or a plain `DocEnvelope` object — no running HTTP server required. Parent directories are created as needed and the envelope is written as pretty JSON.
|
|
91
|
+
|
|
92
|
+
**Step 2 — Run codegen against the file:**
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
tsx scripts/emit-docs.ts
|
|
96
|
+
npx ts-procedures-codegen --file docs.json --out src/generated --service-name Api
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
This is handy for CI pipelines and monorepos where the client is generated as a build step without standing up the server.
|
|
100
|
+
|
|
75
101
|
## Generated File Structure
|
|
76
102
|
|
|
77
103
|
Running the codegen command produces one file per scope, plus shared types, client runtime, error types, and a barrel export:
|
|
@@ -199,6 +225,81 @@ const client = createClient({
|
|
|
199
225
|
})
|
|
200
226
|
```
|
|
201
227
|
|
|
228
|
+
### Authentication (rotating tokens)
|
|
229
|
+
|
|
230
|
+
A bearer token usually changes over the lifetime of a client — it expires and gets refreshed. The catch: a **static** `headers` record is captured **once**, when you create the client (or when you build the per-call options object). Whatever token value was in scope at that moment is frozen into the request forever:
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
// ⚠️ Goes stale: `session.token` is read once, at client construction.
|
|
234
|
+
const client = createClient({
|
|
235
|
+
adapter,
|
|
236
|
+
scopes: createApiBindings,
|
|
237
|
+
defaults: {
|
|
238
|
+
headers: { Authorization: `Bearer ${session.token}` },
|
|
239
|
+
},
|
|
240
|
+
})
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
There are two seams for re-evaluating the value on every request.
|
|
244
|
+
|
|
245
|
+
**Function-valued `headers` (recommended).** Instead of a record, pass a function. It's invoked (and awaited, if it returns a promise) on every call, so the current token is always read fresh:
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
const client = createClient({
|
|
249
|
+
adapter,
|
|
250
|
+
scopes: createApiBindings,
|
|
251
|
+
defaults: {
|
|
252
|
+
// Re-evaluated per request — never goes stale.
|
|
253
|
+
headers: () => ({ Authorization: `Bearer ${session.token}` }),
|
|
254
|
+
},
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// The function may be async — useful when the token is loaded on demand:
|
|
258
|
+
const client = createClient({
|
|
259
|
+
adapter,
|
|
260
|
+
scopes: createApiBindings,
|
|
261
|
+
defaults: {
|
|
262
|
+
headers: async () => ({ Authorization: `Bearer ${await getAccessToken()}` }),
|
|
263
|
+
},
|
|
264
|
+
})
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
The function form works everywhere a `headers` record does — client `defaults.headers` and per-call `options.headers` — and the same precedence applies: per-call headers (function or record) win over default headers, and route-declared headers from `schema.input.headers` win over both. A per-call function override:
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
await client.users.GetUser(
|
|
271
|
+
{ pathParams: { id: '123' } },
|
|
272
|
+
{ headers: () => ({ Authorization: `Bearer ${oneOffToken}` }) },
|
|
273
|
+
)
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
**The `auth` shorthand (most concise).** For the common case — a single rotating bearer token — pass `auth: () => session.token` directly on the client config. It's sugar over function-valued `headers`: re-evaluated per request, wired to `Authorization: Bearer <token>` internally, and a `null`/`undefined` return omits the header (handy while a session is still loading). It composes with `defaults.headers` (both are applied) and remains overridable by per-call `headers` and `onBeforeRequest`:
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
const client = createClient({
|
|
280
|
+
adapter,
|
|
281
|
+
scopes: createApiBindings,
|
|
282
|
+
auth: () => session.token, // may be async; null/undefined → no Authorization header
|
|
283
|
+
})
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
**The `onBeforeRequest` hook (alternative).** If you need full access to the outgoing request — not just the header values — mutate it from the hook. The hook runs last and has the final say (see [Precedence](#precedence)):
|
|
287
|
+
|
|
288
|
+
```typescript
|
|
289
|
+
const client = createClient({
|
|
290
|
+
adapter,
|
|
291
|
+
scopes: createApiBindings,
|
|
292
|
+
hooks: {
|
|
293
|
+
onBeforeRequest(ctx) {
|
|
294
|
+
ctx.request.headers = { ...ctx.request.headers, Authorization: `Bearer ${session.token}` }
|
|
295
|
+
return ctx
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
})
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
> **Warning:** A token placed in a **static** `headers` record (`headers: { Authorization: ... }`) type-checks fine and silently goes stale — the request keeps sending whatever token was current at construction time. For any value that changes between calls, use the function form or `onBeforeRequest`.
|
|
302
|
+
|
|
202
303
|
### Typed Per-Request Metadata
|
|
203
304
|
|
|
204
305
|
Every `AdapterRequest` carries an optional `meta` field typed via the `RequestMeta` interface. `RequestMeta` is declared empty by design — augment it in your own project via TypeScript declaration merging and your fields become typed end-to-end: in per-call options, in hook contexts, and inside your adapter.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Handoff: x-named-type reference should win over a same-named structural extraction
|
|
2
|
+
|
|
3
|
+
> **STATUS: OPEN REQUEST — not yet shipped in ajsc.** ts-procedures currently DETECTS this collision and throws a route-qualified error, rejecting an otherwise-valid schema. We'd retire that detect-and-error once ajsc disambiguates per the proposal below.
|
|
4
|
+
|
|
5
|
+
**To:** ajsc maintainer
|
|
6
|
+
**From:** ts-procedures codegen (consumer of ajsc as the JSON-Schema → TS/Kotlin/Swift emitter)
|
|
7
|
+
**Date:** 2026-06-06
|
|
8
|
+
**ajsc version inspected:** 7.3.0
|
|
9
|
+
**Priority:** medium — removes a DX wart in ts-procedures (a valid schema is rejected); no current miscompilation, because we detect-and-error rather than emit wrong code
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## TL;DR
|
|
14
|
+
|
|
15
|
+
`x-named-type` (shipped in 7.3.0, thank you) lets ts-procedures reference a shared model `Message` instead of inlining it. But when a route schema *also* has a property whose ajsc-derived extraction name equals that model name, ajsc **silently merges** the two: the external reference resolves to the unrelated structural sub-type. Your README already documents this caveat and the detection signal (`extractedTypeNames` ∩ `referencedNamedTypes`).
|
|
16
|
+
|
|
17
|
+
The ask: make an `x-named-type` reference **take precedence** over a would-be structural extraction of the same name — rename the *extraction*, keep the *reference* verbatim. The reference is an explicit author intent ("this is the external `Message`"); the extraction name is an ajsc-derived convenience. Intent should win.
|
|
18
|
+
|
|
19
|
+
Additive and opt-in in effect (only changes output for schemas that currently collide, which today produce silently-wrong code). Nothing changes when no collision exists.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Why this happens
|
|
24
|
+
|
|
25
|
+
ts-procedures hoists every `$id`-bearing subschema into a shared `_models.ts` and rewrites each route schema's occurrence into `{ "x-named-type": "<Name>" }` so routes *reference* the model rather than inline it (the integration contract from the prior `x-named-type` handoff, now live).
|
|
26
|
+
|
|
27
|
+
Separately, with `inlineTypes: false`, ajsc extracts inline nested objects into sub-types **named after the property** (PascalCased). So a route schema can independently produce an extracted sub-type whose name happens to equal a model's title.
|
|
28
|
+
|
|
29
|
+
When those two names coincide, ajsc's `$ref`/extraction merge collapses them. Because the merge happens **inside ajsc, before codegen sees any output**, a post-hoc rename downstream can't fix it — renaming the emitted text would rewrite the model reference too, yielding a silently-wrong type. The merge is lossy at the source.
|
|
30
|
+
|
|
31
|
+
## What ajsc 7.3.0 does today (the blocker)
|
|
32
|
+
|
|
33
|
+
A route property named `message` (an inline object) PascalCases to an extracted sub-type `Message`. A sibling property `latest` references the external model via `{ "x-named-type": "Message" }`. The reference and the extraction share the name `Message`, and ajsc merges them — `latest` resolves to the structural `{ unread }`, **not** the external `Message`.
|
|
34
|
+
|
|
35
|
+
```jsonc
|
|
36
|
+
// input route schema (inlineTypes: false)
|
|
37
|
+
{ "type": "object", "properties": {
|
|
38
|
+
"latest": { "x-named-type": "Message" }, // external ref → shared _models.ts Message
|
|
39
|
+
"message": { "type": "object", "title": "Message", // inline → ajsc extracts a sub-type "Message"
|
|
40
|
+
"properties": { "unread": { "type": "boolean" } } } } }
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// ajsc 7.3.0 output — the extracted structural type wins; the external reference is lost
|
|
45
|
+
export type Message = { unread?: boolean }; // structural extraction (from `message`)
|
|
46
|
+
export type Root = { latest?: Message; message?: Message };
|
|
47
|
+
// ^^^^^^^ WRONG — `latest` now points at { unread } instead of the
|
|
48
|
+
// real shared Message model. The x-named-type intent was silently merged away.
|
|
49
|
+
// converter.referencedNamedTypes === ['Message'] AND converter.extractedTypeNames === ['Message', …]
|
|
50
|
+
// → both lists contain 'Message': the documented collision signal.
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
// desired output — the reference wins, the extraction is renamed
|
|
55
|
+
export type MessageRef = { unread?: boolean }; // structural extraction, disambiguated
|
|
56
|
+
export type Root = { latest?: Message; message?: MessageRef };
|
|
57
|
+
// ^^^^^^^ correct — external Message preserved (imported from _models)
|
|
58
|
+
// ^^^^^^^^^^ structural sub-type disambiguated
|
|
59
|
+
// converter.extractedTypeNames === ['MessageRef', …] // the rename is reported back
|
|
60
|
+
// converter.referencedNamedTypes === ['Message'] // reference untouched
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The reference is what the author explicitly asked for; the extraction name is incidental. The reference should be the one preserved verbatim.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Requested feature
|
|
68
|
+
|
|
69
|
+
When a name referenced via `x-named-type` would collide with a name ajsc is about to assign to an *extracted* structural sub-type, **the reference wins**: keep the referenced name verbatim and disambiguate the extraction. Pick whichever of the following best fits ajsc's internals — listed in our order of preference:
|
|
70
|
+
|
|
71
|
+
### Option (a) — x-named-type references win (recommended)
|
|
72
|
+
|
|
73
|
+
When a referenced name collides with a would-be extraction, **rename the extraction** (append a suffix — `Ref`, `Inner`, or a numeric tail like ajsc already uses elsewhere) and keep the reference verbatim. Surface the *final* extraction name in `extractedTypeNames` so the caller sees the rename. The referenced name in `referencedNamedTypes` is unchanged.
|
|
74
|
+
|
|
75
|
+
This is the most "just works" outcome: the schema author gets a correct external reference and a (renamed) structural type, with zero new API surface. It mirrors ajsc's existing sub-type-naming disambiguation, just seeded by the set of referenced names.
|
|
76
|
+
|
|
77
|
+
### Option (b) — a `reservedExtractionNames` converter option
|
|
78
|
+
|
|
79
|
+
Add a converter option — `reservedExtractionNames?: Set<string>` (or `reservedNames`) — so the caller can hand ajsc the set of model names up front and guarantee extractions avoid them (renaming on conflict). This puts the caller in control and is explicit, at the cost of one new option. ts-procedures would pass the full set of `_models.ts` names. Functionally equivalent to (a) for our case; (a) is preferable because it needs no caller wiring and protects every consumer by default.
|
|
80
|
+
|
|
81
|
+
### Option (c) — explicit no-op acknowledgement
|
|
82
|
+
|
|
83
|
+
If you prefer to keep disambiguation as the caller's responsibility, a short note in the README confirming that — and that the documented `extractedTypeNames` ∩ `referencedNamedTypes` signal is the intended detection mechanism — lets us keep our detect-and-error in good conscience. Least preferred: it leaves a valid schema rejectable downstream.
|
|
84
|
+
|
|
85
|
+
**Recommendation: (a).** It removes the failure mode for every consumer with no new API and matches the principle that an explicit reference outranks a derived extraction name.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Current downstream interim (the DX wart)
|
|
90
|
+
|
|
91
|
+
ts-procedures DETECTS the collision and throws rather than emit wrong code. The check (`assertNoModelNameCollision` in `src/codegen/emit-scope.ts`) is the **set intersection** of the converter's two reported lists — ajsc's documented signal:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
collision = referencedNamedTypes ∩ extractedTypeNames
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
A non-empty intersection means a referenced model name also appears as an ajsc-extracted declaration — i.e. the silent merge happened. We throw a clear, route-qualified error telling the author to rename the colliding property or change the model's `$id`/`title`. This is correct but unfortunate: the schema is *valid*, and the author is forced to rename something to satisfy a code-generator limitation. We'd retire this the moment ajsc disambiguates.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## How ts-procedures will consume it (integration contract)
|
|
102
|
+
|
|
103
|
+
Once ajsc disambiguates (option (a) or (b)):
|
|
104
|
+
|
|
105
|
+
1. We **drop the detect-and-error** in `emit-scope.ts` — or downgrade it to a defensive `assert` (a non-empty intersection should then be impossible, so a remaining one would indicate an ajsc regression rather than a user error).
|
|
106
|
+
2. The colliding schema **just works**: `latest: Message` resolves to the shared `_models.ts` model; the structural sibling emits under its disambiguated name (e.g. `MessageRef`), referenced correctly from the route type.
|
|
107
|
+
3. We continue to read `referencedNamedTypes` for imports (unchanged) and `extractedTypeNames` for the route's local type set — which, under option (a), now carries the renamed extraction automatically.
|
|
108
|
+
|
|
109
|
+
The contract we depend on: **a referenced `x-named-type` name is never overwritten by an extraction — the extraction yields**, and the final extraction name is reported in `extractedTypeNames`.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Tests worth adding
|
|
114
|
+
|
|
115
|
+
1. `x-named-type: "Message"` sibling to an inline object property `message` (PascalCases to `Message`) ⇒ reference resolves to external `Message`; extraction renamed (e.g. `MessageRef`); `extractedTypeNames` carries the renamed name; `referencedNamedTypes === ['Message']`; the two lists no longer intersect.
|
|
116
|
+
2. Same model referenced at N sites alongside one colliding extraction ⇒ all references stay verbatim; only the extraction is renamed.
|
|
117
|
+
3. Multiple distinct collisions in one schema ⇒ each extraction independently disambiguated; references all preserved.
|
|
118
|
+
4. No collision (referenced name ≠ any extraction name) ⇒ byte-identical to current 7.3.0 output (regression guard).
|
|
119
|
+
5. Option (b) only: `reservedExtractionNames` containing a name that *would not* otherwise collide ⇒ extraction proactively avoids it; references unaffected.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Versioning
|
|
124
|
+
|
|
125
|
+
Additive in effect — it only changes output for schemas that **currently miscompile** (a silent merge). For every non-colliding schema the output is byte-identical. A **minor** bump (e.g. 7.4.0). ts-procedures will gate the removal of its detect-and-error on the ajsc version that introduces the precedence rule, keeping the guard as a fallback for one release if you'd prefer a soft cutover.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Contact / context
|
|
130
|
+
|
|
131
|
+
- `src/codegen/emit-scope.ts` — `assertNoModelNameCollision` (the detect-and-error we want to retire)
|
|
132
|
+
- `src/codegen/emit-types.ts` — `referencedNamedTypes` already surfaced (`ExtractedTypeOutput.referencedNamedTypes`, `jsonSchemaToTypeBodyWithRefs`); `extractedTypeNames` not yet surfaced, will be wired when (a)/(b) lands
|
|
133
|
+
- `src/codegen/collect-models.ts`, `emit-models.ts` — `$id` model collection / `_models.ts` emission (unaffected)
|
|
134
|
+
- `docs/handoffs/ajsc-named-type-support.md` — the prior (shipped) `x-named-type` handoff this builds on
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# Handoff: named-type / verbatim-type support in ajsc
|
|
2
|
+
|
|
3
|
+
> **STATUS: Shipped in ajsc 7.3.0 and adopted in ts-procedures (this cutover).** The placeholder-token workaround described below has been deleted; `substituteModelRefs` now emits `{ 'x-named-type': '<Name>' }` and the codegen reads the converter's `referencedNamedTypes`. The rest of this doc is retained for historical context.
|
|
4
|
+
|
|
5
|
+
**To:** ajsc maintainer
|
|
6
|
+
**From:** ts-procedures codegen (consumer of ajsc as the JSON-Schema → TS/Kotlin/Swift emitter)
|
|
7
|
+
**Date:** 2026-06-05
|
|
8
|
+
**ajsc version inspected:** 7.2.0
|
|
9
|
+
**Priority:** medium — unblocks removing a workaround in ts-procedures, no current breakage
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## TL;DR
|
|
14
|
+
|
|
15
|
+
ts-procedures needs a way to tell ajsc: *"this subschema is an already-defined named type called `Message` — emit a reference to it (`Message`), don't convert/inline it, and tell me you referenced it so I can add an import."*
|
|
16
|
+
|
|
17
|
+
ajsc 7.2.0 has no such mechanism: a `$ref` to a `$defs` entry is **inlined and re-extracted under the property name** (with no dedup), and there is no verbatim-type escape hatch. We've worked around this downstream with a placeholder-token hack (described below) that we'd like to delete once ajsc supports this directly.
|
|
18
|
+
|
|
19
|
+
The ask is small and **additive** (a new opt-in schema keyword + one new field on the emit result). Nothing changes when the keyword is absent.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Why we need it
|
|
24
|
+
|
|
25
|
+
ts-procedures generates a typed API client from a server's schema. The same domain entity (e.g. `Message`) appears in many route schemas. Today ajsc inlines a fresh structural literal at every site, so `Message` is emitted ~4–6 times under different names in one file, disconnected from each other and from the author's `Message` type. We want **one** `Message` type that every route references.
|
|
26
|
+
|
|
27
|
+
We already know the identity: each such schema carries a stable `$id` (e.g. `urn:msg`) and a `title` (`Message`). So at codegen time we hoist every `$id`-bearing schema into a shared `_models.ts` (emitted via `ajsc.emitTypescript(messageSchema, { rootTypeName: 'Message' })` — which works great), and we want each *route* schema to **reference** `Message` rather than inline it.
|
|
28
|
+
|
|
29
|
+
## What ajsc 7.2.0 does today (the blocker)
|
|
30
|
+
|
|
31
|
+
Based on inspecting the installed `dist/`:
|
|
32
|
+
|
|
33
|
+
1. **`$ref` is inlined, not referenced.** The IR layer (`ir/JSONSchemaConverter`) resolves `$ref` against `$defs`/`definitions` by inlining, and rejects non-local refs (`"Only local references are supported"`). With `inlineTypes: false`, the inlined object is then re-extracted **named after the property** — so a schema with `author`, `lastReply`, and `all` all `$ref`-ing the same `Message` emits three duplicate types `Author`, `LastReply`, `All`, with **no deduplication** and no way to make them one `Message`.
|
|
34
|
+
|
|
35
|
+
```jsonc
|
|
36
|
+
// input
|
|
37
|
+
{ "type": "object", "properties": {
|
|
38
|
+
"author": { "$ref": "#/$defs/Message" },
|
|
39
|
+
"lastReply": { "$ref": "#/$defs/Message" } },
|
|
40
|
+
"$defs": { "Message": { "type": "object", "title": "Message",
|
|
41
|
+
"properties": { "id": { "type": "string" } } } } }
|
|
42
|
+
```
|
|
43
|
+
```ts
|
|
44
|
+
// ajsc 7.2.0 output (inlineTypes:false) — duplicated, property-named, no dedup
|
|
45
|
+
export type Author = { id?: string };
|
|
46
|
+
export type LastReply = { id?: string };
|
|
47
|
+
export type Root = { author?: Author; lastReply?: LastReply };
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
2. **No verbatim-type escape hatch.** Grepping `dist/` for `tsType | x-ts | verbatim | existingType | customType | rawType` returns nothing. We tried `{ tsType: 'Message' }`, `{ "x-tsType": 'Message' }`, `{ $ref: 'Message' }` (bare), `{ type:'object', tsType:'Message' }` — all are ignored (emit `any` / `{ [key:string]: unknown }`) or throw.
|
|
51
|
+
|
|
52
|
+
3. **`EmitResult.imports` is empty for TypeScript.** The function-form `emitTypescript(schema, opts)` returns `{ code, rootTypeName, extractedTypeNames, imports }`, but `imports` is documented (README ~line 110) as Kotlin/Swift-only — there's no channel to surface "this TS output references external type `Message`."
|
|
53
|
+
|
|
54
|
+
### Our current workaround (what we want to delete)
|
|
55
|
+
|
|
56
|
+
Because ajsc can't reference a named type, ts-procedures smuggles a sentinel through ajsc and scrapes it back out:
|
|
57
|
+
|
|
58
|
+
1. Before calling ajsc, replace each `$id`-bearing subschema with `{ const: '__MODELREF__Message__' }`. ajsc emits that verbatim as a string-literal type (`author?: "__MODELREF__Message__"`), and — usefully — never extracts it as a sub-type, and survives inside `Array<…>`.
|
|
59
|
+
2. After ajsc, a global regex `/["']__MODELREF__([A-Za-z_$][\w$]*)__["']/g` rewrites the tokens to bare names (`author?: Message`) and collects the referenced names so we can emit `import type { Message } from './_models'`.
|
|
60
|
+
|
|
61
|
+
It works and is well-tested, but it encodes a *type identity* as schema *data*, runs it through a code generator, and recovers it from generated *text* with a regex. We'd much rather express the intent directly to ajsc.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Requested feature
|
|
66
|
+
|
|
67
|
+
A new **opt-in schema keyword** that marks a subschema as an already-defined named type. When present, ajsc:
|
|
68
|
+
|
|
69
|
+
1. emits a **reference** to that name (does not convert/inline the subschema, does not extract a sub-type), and
|
|
70
|
+
2. **reports** the referenced name on the emit result so the caller can wire an import.
|
|
71
|
+
|
|
72
|
+
### Keyword name & shape
|
|
73
|
+
|
|
74
|
+
ajsc is multi-target (TS/Kotlin/Swift), so prefer a target-agnostic keyword over a TS-specific one. Suggested:
|
|
75
|
+
|
|
76
|
+
```jsonc
|
|
77
|
+
{ "x-named-type": "Message" }
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`x-`-prefixed so it's unambiguously an annotation and ignored by standard JSON-Schema validators. (If you'd rather not use `x-`, `namedType` or `typeRef` are fine — your call on convention.)
|
|
81
|
+
|
|
82
|
+
- **Value = string:** the identifier emitted verbatim in **every** target (`Message` in TS, Kotlin, and Swift). This matches our usage — we use the same PascalCase name across targets.
|
|
83
|
+
- **Optional future extension:** allow an object for per-language names, e.g. `{ "x-named-type": { "ts": "Message", "kotlin": "Message", "swift": "Message" } }`. Not needed by us now; mentioning so the string form can widen later without a breaking change.
|
|
84
|
+
|
|
85
|
+
### Semantics
|
|
86
|
+
|
|
87
|
+
- The keyword **short-circuits conversion** of that subschema node: emit the bare identifier as the type, in all the positions ajsc already handles a leaf type (direct property, `Array<…>` items, union member, map value, etc.).
|
|
88
|
+
- With `inlineTypes: false`, the named type is **not** added to `extractedTypeNames` (it's external — defined elsewhere).
|
|
89
|
+
- The node is **not** recursed into (its `properties`/`items` are irrelevant once it's a named reference). A `type`/`properties` sitting alongside `x-named-type` should be ignored (or, if you prefer strictness, validated to match — but ignore is simpler and matches our need; we strip the body anyway).
|
|
90
|
+
- **Absent keyword ⇒ zero behaviour change.** Fully backward compatible.
|
|
91
|
+
|
|
92
|
+
### Reporting referenced names
|
|
93
|
+
|
|
94
|
+
Add referenced external names to the emit result so the caller can build imports. Two acceptable shapes:
|
|
95
|
+
|
|
96
|
+
- **Preferred:** a new field `referencedNamedTypes: string[]` on `EmitResult` (deduped, sorted or insertion-order — either is fine; we sort downstream).
|
|
97
|
+
- **Or:** populate the existing (currently-empty-for-TS) `imports` field with the bare names.
|
|
98
|
+
|
|
99
|
+
A new dedicated field is cleaner than overloading `imports` (which means module paths for Kotlin/Swift).
|
|
100
|
+
|
|
101
|
+
### Worked example
|
|
102
|
+
|
|
103
|
+
```jsonc
|
|
104
|
+
// input
|
|
105
|
+
{ "type": "object", "properties": {
|
|
106
|
+
"author": { "x-named-type": "Message" },
|
|
107
|
+
"replies": { "type": "array", "items": { "x-named-type": "Message" } },
|
|
108
|
+
"id": { "type": "string" } } }
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
// desired TS output (inlineTypes:false)
|
|
113
|
+
export type Root = { author?: Message; replies?: Array<Message>; id?: string };
|
|
114
|
+
// emitResult.referencedNamedTypes === ['Message'] // Root is NOT polluted with a Message decl
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
```kotlin
|
|
118
|
+
// desired Kotlin output
|
|
119
|
+
data class Root(val author: Message? = null, val replies: List<Message>? = null, val id: String? = null)
|
|
120
|
+
// referencedNamedTypes === ['Message']
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
```swift
|
|
124
|
+
// desired Swift output
|
|
125
|
+
struct Root: Codable { let author: Message?; let replies: [Message]?; let id: String? }
|
|
126
|
+
// referencedNamedTypes === ['Message']
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
In every case ajsc does **not** define `Message` — the caller (ts-procedures) defines it once elsewhere and adds the import/reference.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Where this likely lives in ajsc
|
|
134
|
+
|
|
135
|
+
From the `dist/` surface (you know the real source layout):
|
|
136
|
+
|
|
137
|
+
- **IR conversion** (`ir/JSONSchemaConverter` or equivalent): detect `x-named-type` on a node early and lower it to a new IR node kind — e.g. `NamedTypeRef(name)` — instead of converting the object. This is the one place that needs to intercept before the existing `$ref`-inline / object-extract logic runs.
|
|
138
|
+
- **Each language emitter** (`typescript/`, `kotlin/`, `swift/`): render `NamedTypeRef(name)` as the bare identifier wherever a leaf type is rendered, and **collect** the name into the result.
|
|
139
|
+
- **`EmitResult` type** (`index.d.ts`): add `referencedNamedTypes?: string[]` (or document `imports` carrying them for TS).
|
|
140
|
+
- **`BaseConverterOpts`:** no new option needed — the keyword lives in the schema, not the options. (You *could* add a `validateNamedTypes` strictness flag later; not required.)
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Tests worth adding (mirror our spike findings)
|
|
145
|
+
|
|
146
|
+
1. `x-named-type` on a direct property ⇒ `prop?: Message`, `referencedNamedTypes` includes `Message`, no `Message` extracted.
|
|
147
|
+
2. Inside `array.items` ⇒ `Array<Message>` (TS) / `List<Message>` (Kotlin) / `[Message]` (Swift).
|
|
148
|
+
3. Same name referenced by N properties ⇒ deduped to a single `Message` reference, `referencedNamedTypes === ['Message']` (the dedup we can't get today).
|
|
149
|
+
4. As a whole root schema (`{ "x-named-type": "Message" }` at top level) ⇒ output is just `Message` (our route-response-is-exactly-a-model case).
|
|
150
|
+
5. Both `inlineTypes: true` and `inlineTypes: false` behave identically for the referenced name (it's a leaf either way).
|
|
151
|
+
6. Absent keyword ⇒ byte-identical to current output (regression guard).
|
|
152
|
+
7. Coexists with genuine sibling extraction: a schema with one `x-named-type` property and one ordinary nested object still extracts the ordinary object normally under `inlineTypes:false`.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## How ts-procedures will consume it (integration contract)
|
|
157
|
+
|
|
158
|
+
So you can sanity-check the design against the real caller:
|
|
159
|
+
|
|
160
|
+
1. We already collect every `$id`-bearing subschema into a registry `{ $id, name, schema }` and emit each one standalone via `emitTypescript(schema, { rootTypeName: name })` into `_models.ts`. **No change needed there.**
|
|
161
|
+
2. For each *route* schema, instead of our placeholder-token substitution, we'll walk the schema and replace each `$id`-bearing subschema with `{ "x-named-type": <name> }` before calling ajsc.
|
|
162
|
+
3. We read `emitResult.referencedNamedTypes` to emit `import type { Message, … } from './_models'` per scope file.
|
|
163
|
+
4. We delete `model-refs.ts` (the placeholder/regex module) entirely and the two wrapper functions in `emit-scope.ts`.
|
|
164
|
+
|
|
165
|
+
The contract we depend on: **(a)** a referenced `x-named-type` is never inlined and never extracted, and **(b)** its name is reported back. Those two guarantees are the whole feature.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Versioning
|
|
170
|
+
|
|
171
|
+
Additive and opt-in ⇒ a **minor** bump (e.g. 7.3.0). No migration for existing consumers. ts-procedures will gate on the ajsc version that introduces `referencedNamedTypes` and keep the placeholder-token path as a fallback for one release if you'd like a soft cutover, or hard-switch if you prefer.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Contact / context
|
|
176
|
+
|
|
177
|
+
The placeholder-token workaround, the design rationale, and the empirical ajsc probes live in the ts-procedures repo:
|
|
178
|
+
- `src/codegen/model-refs.ts` — the current substitution we want to remove
|
|
179
|
+
- `src/codegen/emit-models.ts`, `collect-models.ts` — the model collection/emission (unaffected)
|
|
180
|
+
- `docs/superpowers/specs/2026-06-05-dx-feedback-round-design.md` — design (see finding #3)
|
|
181
|
+
- commit `483185e` — the spike that established ajsc's inlining behaviour and chose the workaround
|