ts-procedures 8.0.0 → 8.1.1
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 +14 -0
- package/build/client/call.test.js +1 -1
- package/build/client/call.test.js.map +1 -1
- package/build/client/errors.js +0 -1
- package/build/client/errors.js.map +1 -1
- package/build/client/hooks.test.js +1 -1
- package/build/client/hooks.test.js.map +1 -1
- package/build/client/index.js +1 -1
- package/build/client/index.js.map +1 -1
- package/build/client/typed-error-dispatch.test.js +6 -3
- package/build/client/typed-error-dispatch.test.js.map +1 -1
- package/build/codegen/bundle-size.test.js +0 -1
- package/build/codegen/bundle-size.test.js.map +1 -1
- package/build/codegen/e2e.test.js +331 -0
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-client-types.test.js +1 -0
- package/build/codegen/emit-client-types.test.js.map +1 -1
- package/build/codegen/emit-scope.js +52 -17
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/emit-scope.test.js +177 -0
- package/build/codegen/emit-scope.test.js.map +1 -1
- package/build/codegen/emit-types.d.ts +6 -2
- package/build/codegen/emit-types.js +81 -20
- package/build/codegen/emit-types.js.map +1 -1
- package/build/codegen/emit-types.test.js +70 -1
- package/build/codegen/emit-types.test.js.map +1 -1
- package/build/codegen/test-helpers/golden.js +0 -1
- package/build/codegen/test-helpers/golden.js.map +1 -1
- package/build/create-http-stream.d.ts +2 -2
- package/build/create-http.d.ts +3 -3
- package/build/create-http.js.map +1 -1
- package/build/create-http.test.js +1 -1
- package/build/create-http.test.js.map +1 -1
- package/build/create-stream.d.ts +2 -2
- package/build/create-stream.test.js +1 -2
- package/build/create-stream.test.js.map +1 -1
- package/build/create.d.ts +2 -2
- package/build/create.test.js +1 -2
- package/build/create.test.js.map +1 -1
- package/build/errors.d.ts +2 -2
- package/build/errors.js.map +1 -1
- package/build/implementations/http/hono/index.d.ts +2 -1
- package/build/implementations/http/hono/index.js.map +1 -1
- package/build/implementations/types.d.ts +1 -1
- package/build/index.d.ts +9 -9
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/index.test.js +0 -1
- package/build/index.test.js.map +1 -1
- package/build/schema/compute-schema.d.ts +3 -3
- package/build/schema/compute-schema.js.map +1 -1
- package/build/schema/extract-json-schema.d.ts +1 -1
- package/build/schema/parser.d.ts +1 -1
- package/build/schema/resolve-schema-lib.d.ts +2 -2
- package/build/schema/types.d.ts +2 -2
- package/build/stack-utils.test.js.map +1 -1
- package/build/types.d.ts +2 -2
- package/docs/decisions/2026-06-02-monorepo-split-evaluation.md +80 -0
- package/docs/npm-workspaces-migration-plan.md +611 -0
- package/package.json +2 -1
- package/src/client/errors.ts +1 -1
- package/src/client/typed-error-dispatch.test.ts +1 -2
- package/src/codegen/bundle-size.test.ts +1 -1
- package/src/codegen/e2e.test.ts +354 -0
- package/src/codegen/emit-scope.test.ts +186 -0
- package/src/codegen/emit-scope.ts +50 -16
- package/src/codegen/emit-types.test.ts +73 -1
- package/src/codegen/emit-types.ts +82 -21
- package/src/codegen/test-helpers/golden.ts +1 -1
- package/src/create-http-stream.ts +2 -2
- package/src/create-http.ts +2 -3
- package/src/create-stream.test.ts +1 -1
- package/src/create-stream.ts +2 -2
- package/src/create.test.ts +1 -1
- package/src/create.ts +2 -2
- package/src/errors.test.ts +1 -1
- package/src/errors.ts +3 -2
- package/src/implementations/http/hono/index.ts +2 -1
- package/src/implementations/http/on-request-error.test.ts +1 -1
- package/src/implementations/http/route-errors.test.ts +1 -1
- package/src/implementations/types.ts +1 -1
- package/src/index.test.ts +1 -1
- package/src/index.ts +2 -2
- package/src/schema/compute-schema.ts +4 -3
- package/src/schema/extract-json-schema.ts +1 -1
- package/src/schema/parser.ts +1 -1
- package/src/schema/resolve-schema-lib.ts +2 -2
- package/src/schema/types.ts +2 -2
- package/src/stack-utils.test.ts +2 -1
- package/src/types.ts +2 -2
package/src/codegen/e2e.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from 'node:path'
|
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
6
|
import type { DocEnvelope, RPCHttpRouteDoc, APIHttpRouteDoc, StreamHttpRouteDoc, HttpStreamRouteDoc, ErrorDoc } from '../implementations/types.js'
|
|
7
7
|
import { runTsc } from './test-helpers/run-tsc.js'
|
|
8
|
+
import { Type } from 'typebox'
|
|
8
9
|
|
|
9
10
|
// ---------------------------------------------------------------------------
|
|
10
11
|
// Fixtures
|
|
@@ -929,4 +930,357 @@ void run
|
|
|
929
930
|
})
|
|
930
931
|
})
|
|
931
932
|
})
|
|
933
|
+
|
|
934
|
+
// ── Duplicate-identifier bug: type shared between params and returnType ──────
|
|
935
|
+
//
|
|
936
|
+
// End-to-end gold-standard guards. ajsc glues sibling extracted sub-types
|
|
937
|
+
// with a single "\n"; emit-types only renames/dedups the first one, so a
|
|
938
|
+
// route whose body and response both yield >=2 sub-types emits the non-first
|
|
939
|
+
// sub-type twice -> `error TS2300: Duplicate identifier`. These compile the
|
|
940
|
+
// real generated output with tsc.
|
|
941
|
+
describe('shared nested types across body/response', () => {
|
|
942
|
+
const contactShape = {
|
|
943
|
+
type: 'object',
|
|
944
|
+
required: ['name', 'address'],
|
|
945
|
+
properties: {
|
|
946
|
+
name: { type: 'string' },
|
|
947
|
+
address: {
|
|
948
|
+
type: 'object',
|
|
949
|
+
required: ['street', 'city'],
|
|
950
|
+
properties: { street: { type: 'string' }, city: { type: 'string' } },
|
|
951
|
+
},
|
|
952
|
+
},
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const sharedNestedEnvelope: DocEnvelope = {
|
|
956
|
+
basePath: '/api',
|
|
957
|
+
headers: [],
|
|
958
|
+
errors: [],
|
|
959
|
+
routes: [
|
|
960
|
+
{
|
|
961
|
+
kind: 'rpc',
|
|
962
|
+
name: 'SaveContact',
|
|
963
|
+
path: '/contacts/save/1',
|
|
964
|
+
method: 'post',
|
|
965
|
+
scope: 'contacts',
|
|
966
|
+
version: 1,
|
|
967
|
+
jsonSchema: {
|
|
968
|
+
body: { type: 'object', required: ['contact'], properties: { contact: contactShape } },
|
|
969
|
+
response: {
|
|
970
|
+
type: 'object',
|
|
971
|
+
required: ['contact', 'savedAt'],
|
|
972
|
+
properties: { savedAt: { type: 'string' }, contact: contactShape },
|
|
973
|
+
},
|
|
974
|
+
},
|
|
975
|
+
} satisfies RPCHttpRouteDoc,
|
|
976
|
+
],
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
it('generated client compiles when a nested object is shared between body and response', async () => {
|
|
980
|
+
tmpDir = makeTmpDir()
|
|
981
|
+
await generateClient({
|
|
982
|
+
envelope: sharedNestedEnvelope,
|
|
983
|
+
outDir: tmpDir,
|
|
984
|
+
selfContained: true,
|
|
985
|
+
namespaceTypes: true,
|
|
986
|
+
})
|
|
987
|
+
|
|
988
|
+
runTsc({
|
|
989
|
+
tmpDir,
|
|
990
|
+
tsconfigInline: {
|
|
991
|
+
compilerOptions: {
|
|
992
|
+
strict: true,
|
|
993
|
+
target: 'ES2022',
|
|
994
|
+
module: 'ES2022',
|
|
995
|
+
moduleResolution: 'bundler',
|
|
996
|
+
verbatimModuleSyntax: true,
|
|
997
|
+
noEmit: true,
|
|
998
|
+
skipLibCheck: true,
|
|
999
|
+
},
|
|
1000
|
+
include: ['_types.ts', '_client.ts', 'index.ts', 'contacts.ts', '_errors.ts'],
|
|
1001
|
+
},
|
|
1002
|
+
})
|
|
1003
|
+
})
|
|
1004
|
+
|
|
1005
|
+
// Latent correctness guard: same property name (`detail`), DIFFERENT shape
|
|
1006
|
+
// in body vs response. If the rename leaves the response's sub-type pointing
|
|
1007
|
+
// at the body's `Detail`, a consumer that reads the response-only field fails
|
|
1008
|
+
// to compile — catching the silent wrong-reference bug that TS2300 hides when
|
|
1009
|
+
// the shapes happen to be structurally identical.
|
|
1010
|
+
const divergentEnvelope: DocEnvelope = {
|
|
1011
|
+
basePath: '/api',
|
|
1012
|
+
headers: [],
|
|
1013
|
+
errors: [],
|
|
1014
|
+
routes: [
|
|
1015
|
+
{
|
|
1016
|
+
kind: 'rpc',
|
|
1017
|
+
name: 'SyncData',
|
|
1018
|
+
path: '/sync/1',
|
|
1019
|
+
method: 'post',
|
|
1020
|
+
scope: 'sync',
|
|
1021
|
+
version: 1,
|
|
1022
|
+
jsonSchema: {
|
|
1023
|
+
body: {
|
|
1024
|
+
type: 'object',
|
|
1025
|
+
required: ['outer'],
|
|
1026
|
+
properties: {
|
|
1027
|
+
outer: {
|
|
1028
|
+
type: 'object',
|
|
1029
|
+
required: ['detail'],
|
|
1030
|
+
properties: {
|
|
1031
|
+
detail: {
|
|
1032
|
+
type: 'object',
|
|
1033
|
+
required: ['fromClient'],
|
|
1034
|
+
properties: { fromClient: { type: 'string' } },
|
|
1035
|
+
},
|
|
1036
|
+
},
|
|
1037
|
+
},
|
|
1038
|
+
},
|
|
1039
|
+
},
|
|
1040
|
+
response: {
|
|
1041
|
+
type: 'object',
|
|
1042
|
+
required: ['outer'],
|
|
1043
|
+
properties: {
|
|
1044
|
+
outer: {
|
|
1045
|
+
type: 'object',
|
|
1046
|
+
required: ['detail'],
|
|
1047
|
+
properties: {
|
|
1048
|
+
detail: {
|
|
1049
|
+
type: 'object',
|
|
1050
|
+
required: ['fromServer'],
|
|
1051
|
+
properties: { fromServer: { type: 'number' } },
|
|
1052
|
+
},
|
|
1053
|
+
},
|
|
1054
|
+
},
|
|
1055
|
+
},
|
|
1056
|
+
},
|
|
1057
|
+
},
|
|
1058
|
+
} satisfies RPCHttpRouteDoc,
|
|
1059
|
+
],
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
it('response sub-type keeps its own shape when a name collides with a differently-shaped body sub-type', async () => {
|
|
1063
|
+
tmpDir = makeTmpDir()
|
|
1064
|
+
await generateClient({
|
|
1065
|
+
envelope: divergentEnvelope,
|
|
1066
|
+
outDir: tmpDir,
|
|
1067
|
+
selfContained: true,
|
|
1068
|
+
namespaceTypes: true,
|
|
1069
|
+
})
|
|
1070
|
+
|
|
1071
|
+
// Consumer asserts the response's `detail` exposes `fromServer: number`
|
|
1072
|
+
// (its own shape) — not the body's `{ fromClient: string }`. Imports the
|
|
1073
|
+
// scope file directly to validate the generated route type precisely.
|
|
1074
|
+
const consumer = `
|
|
1075
|
+
import type { Sync } from './sync'
|
|
1076
|
+
const r: Sync.SyncData.Response = null as any
|
|
1077
|
+
const n: number = r.outer.detail.fromServer
|
|
1078
|
+
void n
|
|
1079
|
+
`
|
|
1080
|
+
writeFileSync(join(tmpDir, '__consumer.ts'), consumer)
|
|
1081
|
+
|
|
1082
|
+
runTsc({
|
|
1083
|
+
tmpDir,
|
|
1084
|
+
tsconfigInline: {
|
|
1085
|
+
compilerOptions: {
|
|
1086
|
+
strict: true,
|
|
1087
|
+
target: 'ES2022',
|
|
1088
|
+
module: 'ES2022',
|
|
1089
|
+
moduleResolution: 'bundler',
|
|
1090
|
+
verbatimModuleSyntax: true,
|
|
1091
|
+
noEmit: true,
|
|
1092
|
+
skipLibCheck: true,
|
|
1093
|
+
},
|
|
1094
|
+
include: ['_types.ts', '_client.ts', 'index.ts', 'sync.ts', '_errors.ts', '__consumer.ts'],
|
|
1095
|
+
},
|
|
1096
|
+
})
|
|
1097
|
+
})
|
|
1098
|
+
})
|
|
1099
|
+
|
|
1100
|
+
// ── Complex TypeBox-derived schemas (broad duplication / correctness sweep) ──
|
|
1101
|
+
//
|
|
1102
|
+
// Builds realistic schemas with TypeBox, round-trips them to plain JSON Schema
|
|
1103
|
+
// (exactly what lands in a DocEnvelope), and stresses every known collision
|
|
1104
|
+
// vector at once:
|
|
1105
|
+
// - a deeply-nested object (Product → Manufacturer → Hq/Warehouse → Geo)
|
|
1106
|
+
// shared between a route's body AND response (forces the response copy to
|
|
1107
|
+
// rename all of its sub-types and re-point every cross-reference);
|
|
1108
|
+
// - the same Product reused by a SECOND route in the same scope (separate
|
|
1109
|
+
// route namespaces must not clash);
|
|
1110
|
+
// - a sub-type (Geo) referenced by two siblings (Hq + Warehouse) — the
|
|
1111
|
+
// rename must patch BOTH referrers;
|
|
1112
|
+
// - shared Filter/Range across an API route's query + body channels;
|
|
1113
|
+
// - a body property literally named `params`, colliding with the route's
|
|
1114
|
+
// own `Params` shortName;
|
|
1115
|
+
// - arrays-of-objects, optionals, literal unions, and records.
|
|
1116
|
+
//
|
|
1117
|
+
// Run under the default (inline-union) mode AND `enumStyle: 'enum'`, where a
|
|
1118
|
+
// 3-literal union explodes into three separate enums (plus ajsc's own
|
|
1119
|
+
// internal renaming) — the largest fused declaration block we emit. The tsc
|
|
1120
|
+
// compile is the authoritative duplicate-identifier (TS2300) and
|
|
1121
|
+
// dangling/wrong-reference guard; the consumer additionally pins deep field
|
|
1122
|
+
// access through the renamed types.
|
|
1123
|
+
describe('complex TypeBox-derived schemas', () => {
|
|
1124
|
+
const clean = (s: unknown): Record<string, unknown> =>
|
|
1125
|
+
JSON.parse(JSON.stringify(s)) as Record<string, unknown>
|
|
1126
|
+
|
|
1127
|
+
const Geo = Type.Object({ lat: Type.Number(), lng: Type.Number() })
|
|
1128
|
+
const Address = Type.Object({ street: Type.String(), city: Type.String(), geo: Geo })
|
|
1129
|
+
const Manufacturer = Type.Object({
|
|
1130
|
+
name: Type.String(),
|
|
1131
|
+
hq: Address,
|
|
1132
|
+
warehouses: Type.Array(Address),
|
|
1133
|
+
})
|
|
1134
|
+
const Variant = Type.Object({
|
|
1135
|
+
sku: Type.String(),
|
|
1136
|
+
price: Type.Number(),
|
|
1137
|
+
inStock: Type.Boolean(),
|
|
1138
|
+
})
|
|
1139
|
+
const Product = Type.Object({
|
|
1140
|
+
id: Type.String(),
|
|
1141
|
+
category: Type.Union([Type.Literal('book'), Type.Literal('food'), Type.Literal('toy')]),
|
|
1142
|
+
status: Type.Optional(Type.Union([Type.Literal('active'), Type.Literal('archived')])),
|
|
1143
|
+
dimensions: Type.Object({ w: Type.Number(), h: Type.Number(), d: Type.Number() }),
|
|
1144
|
+
tags: Type.Array(Type.String()),
|
|
1145
|
+
variants: Type.Array(Variant),
|
|
1146
|
+
manufacturer: Manufacturer,
|
|
1147
|
+
})
|
|
1148
|
+
|
|
1149
|
+
const Range = Type.Object({ min: Type.Number(), max: Type.Number() })
|
|
1150
|
+
const Filter = Type.Object({
|
|
1151
|
+
category: Type.Optional(Type.Union([Type.Literal('book'), Type.Literal('food')])),
|
|
1152
|
+
price: Range,
|
|
1153
|
+
})
|
|
1154
|
+
const SavedSearch = Type.Object({ name: Type.String(), filter: Filter })
|
|
1155
|
+
|
|
1156
|
+
// Route 1: rpc — body + response share Product; body also has a `params`
|
|
1157
|
+
// property colliding with the route's Params shortName.
|
|
1158
|
+
const upsertProduct: RPCHttpRouteDoc = {
|
|
1159
|
+
kind: 'rpc',
|
|
1160
|
+
name: 'UpsertProduct',
|
|
1161
|
+
path: '/catalog/upsert/1',
|
|
1162
|
+
method: 'post',
|
|
1163
|
+
scope: 'catalog',
|
|
1164
|
+
version: 1,
|
|
1165
|
+
jsonSchema: {
|
|
1166
|
+
body: clean(Type.Object({ product: Product, params: Type.Object({ dryRun: Type.Boolean() }) })),
|
|
1167
|
+
response: clean(
|
|
1168
|
+
Type.Object({
|
|
1169
|
+
product: Product,
|
|
1170
|
+
audit: Type.Object({
|
|
1171
|
+
revision: Type.Integer(),
|
|
1172
|
+
editor: Type.Object({ id: Type.String(), name: Type.String() }),
|
|
1173
|
+
}),
|
|
1174
|
+
}),
|
|
1175
|
+
),
|
|
1176
|
+
},
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// Route 2: api — query + body share Filter/Range; res reuses Product.
|
|
1180
|
+
const searchCatalog: APIHttpRouteDoc = {
|
|
1181
|
+
kind: 'api',
|
|
1182
|
+
name: 'SearchCatalog',
|
|
1183
|
+
path: '/catalog/search',
|
|
1184
|
+
fullPath: '/api/catalog/search',
|
|
1185
|
+
method: 'post',
|
|
1186
|
+
scope: 'catalog',
|
|
1187
|
+
jsonSchema: {
|
|
1188
|
+
req: {
|
|
1189
|
+
query: clean(Type.Object({ page: Type.Integer(), filter: Type.Optional(Filter) })),
|
|
1190
|
+
body: clean(Type.Object({ saved: Type.Array(SavedSearch) })),
|
|
1191
|
+
},
|
|
1192
|
+
res: {
|
|
1193
|
+
body: clean(
|
|
1194
|
+
Type.Object({
|
|
1195
|
+
items: Type.Array(Product),
|
|
1196
|
+
total: Type.Integer(),
|
|
1197
|
+
byCategory: Type.Record(Type.String(), Type.Integer()),
|
|
1198
|
+
}),
|
|
1199
|
+
),
|
|
1200
|
+
},
|
|
1201
|
+
},
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const complexEnvelope: DocEnvelope = {
|
|
1205
|
+
basePath: '/api',
|
|
1206
|
+
headers: [],
|
|
1207
|
+
errors: [],
|
|
1208
|
+
routes: [upsertProduct, searchCatalog],
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const consumer = `
|
|
1212
|
+
import type { Catalog } from './catalog'
|
|
1213
|
+
|
|
1214
|
+
// Route 1 response: deep access through the shared-and-renamed Product, plus
|
|
1215
|
+
// the sub-type (Geo) shared by two referrers (hq + warehouses[]).
|
|
1216
|
+
const up: Catalog.UpsertProduct.Response = null as any
|
|
1217
|
+
const lat: number = up.product.manufacturer.hq.geo.lat
|
|
1218
|
+
const wlng: number = up.product.manufacturer.warehouses[0]!.geo.lng
|
|
1219
|
+
const sku: string = up.product.variants[0]!.sku
|
|
1220
|
+
const w: number = up.product.dimensions.w
|
|
1221
|
+
const rev: number = up.audit.revision
|
|
1222
|
+
const editorName: string = up.audit.editor.name
|
|
1223
|
+
void lat; void wlng; void sku; void w; void rev; void editorName
|
|
1224
|
+
|
|
1225
|
+
// Route 1 params (body): shared Product + the colliding 'params' property.
|
|
1226
|
+
const body: Catalog.UpsertProduct.Params = null as any
|
|
1227
|
+
const pid: string = body.product.id
|
|
1228
|
+
const dry: boolean = body.params.dryRun
|
|
1229
|
+
void pid; void dry
|
|
1230
|
+
|
|
1231
|
+
// Route 2 request: Filter/Range shared across query + body channels.
|
|
1232
|
+
const req: Catalog.SearchCatalog.Req = null as any
|
|
1233
|
+
const page: number = req.query.page
|
|
1234
|
+
const qMin: number | undefined = req.query.filter?.price.min
|
|
1235
|
+
const bMax: number = req.body.saved[0]!.filter.price.max
|
|
1236
|
+
void page; void qMin; void bMax
|
|
1237
|
+
|
|
1238
|
+
// Route 2 response: array of the shared Product + a record.
|
|
1239
|
+
const res: Catalog.SearchCatalog.Response.Body = null as any
|
|
1240
|
+
const total: number = res.total
|
|
1241
|
+
const firstSku: string = res.items[0]!.variants[0]!.sku
|
|
1242
|
+
const bookCount: number | undefined = res.byCategory['book']
|
|
1243
|
+
void total; void firstSku; void bookCount
|
|
1244
|
+
`
|
|
1245
|
+
|
|
1246
|
+
const tsconfig = {
|
|
1247
|
+
compilerOptions: {
|
|
1248
|
+
strict: true,
|
|
1249
|
+
target: 'ES2022',
|
|
1250
|
+
module: 'ES2022',
|
|
1251
|
+
moduleResolution: 'bundler',
|
|
1252
|
+
verbatimModuleSyntax: true,
|
|
1253
|
+
noEmit: true,
|
|
1254
|
+
skipLibCheck: true,
|
|
1255
|
+
},
|
|
1256
|
+
include: ['_types.ts', '_client.ts', 'index.ts', 'catalog.ts', '_errors.ts', '__consumer.ts'],
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
it('compiles end-to-end with deep shared/renamed types (default inline-union mode)', async () => {
|
|
1260
|
+
tmpDir = makeTmpDir()
|
|
1261
|
+
await generateClient({
|
|
1262
|
+
envelope: complexEnvelope,
|
|
1263
|
+
outDir: tmpDir,
|
|
1264
|
+
selfContained: true,
|
|
1265
|
+
namespaceTypes: true,
|
|
1266
|
+
})
|
|
1267
|
+
writeFileSync(join(tmpDir, '__consumer.ts'), consumer)
|
|
1268
|
+
runTsc({ tmpDir, tsconfigInline: tsconfig })
|
|
1269
|
+
})
|
|
1270
|
+
|
|
1271
|
+
it('compiles end-to-end under enumStyle: enum (union-of-literals explodes into many enums)', async () => {
|
|
1272
|
+
tmpDir = makeTmpDir()
|
|
1273
|
+
await generateClient({
|
|
1274
|
+
envelope: complexEnvelope,
|
|
1275
|
+
outDir: tmpDir,
|
|
1276
|
+
selfContained: true,
|
|
1277
|
+
namespaceTypes: true,
|
|
1278
|
+
ajsc: { enumStyle: 'enum' },
|
|
1279
|
+
})
|
|
1280
|
+
// In enum mode `category` becomes enum unions; the deep object access still
|
|
1281
|
+
// holds, so reuse the consumer (it never pins the enum member types).
|
|
1282
|
+
writeFileSync(join(tmpDir, '__consumer.ts'), consumer)
|
|
1283
|
+
runTsc({ tmpDir, tsconfigInline: tsconfig })
|
|
1284
|
+
})
|
|
1285
|
+
})
|
|
932
1286
|
})
|
|
@@ -1399,4 +1399,190 @@ describe('emitScopeFile http-stream kind', () => {
|
|
|
1399
1399
|
expect(out).toContain('TypedStream<Feed.StreamFeed.Yield, Feed.StreamFeed.ReturnType>')
|
|
1400
1400
|
})
|
|
1401
1401
|
})
|
|
1402
|
+
|
|
1403
|
+
// -------------------------------------------------------------------------
|
|
1404
|
+
// Duplicate-identifier bug: a type shared between params and returnType.
|
|
1405
|
+
//
|
|
1406
|
+
// ajsc (inlineTypes:false) glues SIBLING extracted declarations with a
|
|
1407
|
+
// single "\n" inside one block, separating only the Root with "\n\n". When
|
|
1408
|
+
// a route's request and response schemas each yield >=2 extracted sub-types,
|
|
1409
|
+
// the rename/dedup logic in emit-types only ever sees the FIRST declaration
|
|
1410
|
+
// in each fused block, so the non-first sub-types are emitted twice in the
|
|
1411
|
+
// same namespace -> `error TS2300: Duplicate identifier`.
|
|
1412
|
+
//
|
|
1413
|
+
// These assertions are fix-agnostic: they check the emitted contract (no
|
|
1414
|
+
// duplicate top-level identifiers in a route namespace; references resolve),
|
|
1415
|
+
// not any particular internal representation of the fix.
|
|
1416
|
+
// -------------------------------------------------------------------------
|
|
1417
|
+
describe('shared nested types across params/response (duplicate identifier)', () => {
|
|
1418
|
+
/** Every `export type|enum|interface <Name>` identifier declared in the output. */
|
|
1419
|
+
function declaredNames(output: string): string[] {
|
|
1420
|
+
const names: string[] = []
|
|
1421
|
+
const re = /export\s+(?:type|enum|interface)\s+(\w+)/g
|
|
1422
|
+
let m: RegExpExecArray | null
|
|
1423
|
+
while ((m = re.exec(output)) !== null) names.push(m[1]!)
|
|
1424
|
+
return names
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/** Identifiers that appear more than once. */
|
|
1428
|
+
function duplicates(names: string[]): string[] {
|
|
1429
|
+
const seen = new Set<string>()
|
|
1430
|
+
const dup = new Set<string>()
|
|
1431
|
+
for (const n of names) {
|
|
1432
|
+
if (seen.has(n)) dup.add(n)
|
|
1433
|
+
else seen.add(n)
|
|
1434
|
+
}
|
|
1435
|
+
return [...dup]
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/** All identifiers referenced inside `export type <Name> = <body>` bodies. */
|
|
1439
|
+
function referencedNames(output: string): Set<string> {
|
|
1440
|
+
const refs = new Set<string>()
|
|
1441
|
+
const re = /export\s+(?:type|interface)\s+\w+\s*=?\s*([^\n]*)/g
|
|
1442
|
+
let m: RegExpExecArray | null
|
|
1443
|
+
while ((m = re.exec(output)) !== null) {
|
|
1444
|
+
for (const id of m[1]!.matchAll(/\b([A-Z]\w*)\b/g)) refs.add(id[1]!)
|
|
1445
|
+
}
|
|
1446
|
+
return refs
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
const contactShape = {
|
|
1450
|
+
type: 'object',
|
|
1451
|
+
required: ['name', 'address'],
|
|
1452
|
+
properties: {
|
|
1453
|
+
name: { type: 'string' },
|
|
1454
|
+
address: {
|
|
1455
|
+
type: 'object',
|
|
1456
|
+
required: ['street', 'city'],
|
|
1457
|
+
properties: { street: { type: 'string' }, city: { type: 'string' } },
|
|
1458
|
+
},
|
|
1459
|
+
},
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// body and response both contain `contact` (-> Contact + Address sub-types).
|
|
1463
|
+
const sharedNestedGroup: ScopeGroup = {
|
|
1464
|
+
scopeKey: 'contacts',
|
|
1465
|
+
camelCase: 'contacts',
|
|
1466
|
+
routes: [
|
|
1467
|
+
{
|
|
1468
|
+
kind: 'rpc',
|
|
1469
|
+
name: 'SaveContact',
|
|
1470
|
+
path: '/contacts/save/1',
|
|
1471
|
+
method: 'post',
|
|
1472
|
+
scope: 'contacts',
|
|
1473
|
+
version: 1,
|
|
1474
|
+
jsonSchema: {
|
|
1475
|
+
body: {
|
|
1476
|
+
type: 'object',
|
|
1477
|
+
required: ['contact'],
|
|
1478
|
+
properties: { contact: contactShape },
|
|
1479
|
+
},
|
|
1480
|
+
response: {
|
|
1481
|
+
type: 'object',
|
|
1482
|
+
required: ['contact', 'savedAt'],
|
|
1483
|
+
properties: { savedAt: { type: 'string' }, contact: contactShape },
|
|
1484
|
+
},
|
|
1485
|
+
},
|
|
1486
|
+
} satisfies RPCHttpRouteDoc,
|
|
1487
|
+
],
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
it('does not emit duplicate identifiers when a nested object is shared between body and response', async () => {
|
|
1491
|
+
const output = await emitScopeFile(sharedNestedGroup, { namespaceTypes: true })
|
|
1492
|
+
const dups = duplicates(declaredNames(output))
|
|
1493
|
+
expect(dups).toEqual([])
|
|
1494
|
+
})
|
|
1495
|
+
|
|
1496
|
+
it('every type referenced in a body resolves to a declared (or built-in) name', async () => {
|
|
1497
|
+
const output = await emitScopeFile(sharedNestedGroup, { namespaceTypes: true })
|
|
1498
|
+
const declared = new Set(declaredNames(output))
|
|
1499
|
+
// Built-in / framework type names that need no local declaration.
|
|
1500
|
+
const builtins = new Set(['Array', 'Record', 'Partial', 'Promise', 'TypedStream'])
|
|
1501
|
+
const unresolved = [...referencedNames(output)].filter(
|
|
1502
|
+
(r) => !declared.has(r) && !builtins.has(r),
|
|
1503
|
+
)
|
|
1504
|
+
expect(unresolved).toEqual([])
|
|
1505
|
+
})
|
|
1506
|
+
|
|
1507
|
+
// Two sibling object properties shared between body and response — also
|
|
1508
|
+
// produces a fused 2-declaration block, duplicating the second sub-type.
|
|
1509
|
+
const sharedSiblingsGroup: ScopeGroup = {
|
|
1510
|
+
scopeKey: 'records',
|
|
1511
|
+
camelCase: 'records',
|
|
1512
|
+
routes: [
|
|
1513
|
+
{
|
|
1514
|
+
kind: 'rpc',
|
|
1515
|
+
name: 'PutRecord',
|
|
1516
|
+
path: '/records/1',
|
|
1517
|
+
method: 'post',
|
|
1518
|
+
scope: 'records',
|
|
1519
|
+
version: 1,
|
|
1520
|
+
jsonSchema: {
|
|
1521
|
+
body: {
|
|
1522
|
+
type: 'object',
|
|
1523
|
+
required: ['alpha', 'beta'],
|
|
1524
|
+
properties: {
|
|
1525
|
+
alpha: { type: 'object', required: ['x'], properties: { x: { type: 'string' } } },
|
|
1526
|
+
beta: { type: 'object', required: ['y'], properties: { y: { type: 'string' } } },
|
|
1527
|
+
},
|
|
1528
|
+
},
|
|
1529
|
+
response: {
|
|
1530
|
+
type: 'object',
|
|
1531
|
+
required: ['alpha', 'beta'],
|
|
1532
|
+
properties: {
|
|
1533
|
+
alpha: { type: 'object', required: ['x'], properties: { x: { type: 'string' } } },
|
|
1534
|
+
beta: { type: 'object', required: ['y'], properties: { y: { type: 'string' } } },
|
|
1535
|
+
},
|
|
1536
|
+
},
|
|
1537
|
+
},
|
|
1538
|
+
} satisfies RPCHttpRouteDoc,
|
|
1539
|
+
],
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
it('does not emit duplicate identifiers when two sibling objects are shared', async () => {
|
|
1543
|
+
const output = await emitScopeFile(sharedSiblingsGroup, { namespaceTypes: true })
|
|
1544
|
+
const dups = duplicates(declaredNames(output))
|
|
1545
|
+
expect(dups).toEqual([])
|
|
1546
|
+
})
|
|
1547
|
+
|
|
1548
|
+
// Regression guard for the working case: a single shared nested sub-type
|
|
1549
|
+
// (1 extracted declaration per block) already renames correctly to *Inner.
|
|
1550
|
+
// This MUST stay green — the fix must not regress the single-sub-type path.
|
|
1551
|
+
const singleNestedGroup: ScopeGroup = {
|
|
1552
|
+
scopeKey: 'cities',
|
|
1553
|
+
camelCase: 'cities',
|
|
1554
|
+
routes: [
|
|
1555
|
+
{
|
|
1556
|
+
kind: 'rpc',
|
|
1557
|
+
name: 'SaveCity',
|
|
1558
|
+
path: '/cities/1',
|
|
1559
|
+
method: 'post',
|
|
1560
|
+
scope: 'cities',
|
|
1561
|
+
version: 1,
|
|
1562
|
+
jsonSchema: {
|
|
1563
|
+
body: {
|
|
1564
|
+
type: 'object',
|
|
1565
|
+
required: ['city'],
|
|
1566
|
+
properties: {
|
|
1567
|
+
city: { type: 'object', required: ['name'], properties: { name: { type: 'string' } } },
|
|
1568
|
+
},
|
|
1569
|
+
},
|
|
1570
|
+
response: {
|
|
1571
|
+
type: 'object',
|
|
1572
|
+
required: ['city'],
|
|
1573
|
+
properties: {
|
|
1574
|
+
city: { type: 'object', required: ['name'], properties: { name: { type: 'string' } } },
|
|
1575
|
+
},
|
|
1576
|
+
},
|
|
1577
|
+
},
|
|
1578
|
+
} satisfies RPCHttpRouteDoc,
|
|
1579
|
+
],
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
it('single shared nested sub-type stays free of duplicates (regression guard)', async () => {
|
|
1583
|
+
const output = await emitScopeFile(singleNestedGroup, { namespaceTypes: true })
|
|
1584
|
+
const dups = duplicates(declaredNames(output))
|
|
1585
|
+
expect(dups).toEqual([])
|
|
1586
|
+
})
|
|
1587
|
+
})
|
|
1402
1588
|
})
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
jsonSchemaToTypeBody,
|
|
11
11
|
jsonSchemaToExtractedTypes,
|
|
12
12
|
renameExtractedTypes,
|
|
13
|
+
extractedDeclName,
|
|
13
14
|
type AjscOptions,
|
|
14
15
|
type ExtractedTypeOutput,
|
|
15
16
|
} from './emit-types.js'
|
|
@@ -109,6 +110,45 @@ function indent(text: string, prefix: string): string {
|
|
|
109
110
|
return text.split('\n').map((line) => (line ? prefix + line : line)).join('\n')
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Tracks extracted declarations emitted into a single namespace, guarding
|
|
115
|
+
* against duplicate identifiers (defense-in-depth on top of the rename pass).
|
|
116
|
+
*
|
|
117
|
+
* - Exact-string duplicates (the same sub-type extracted from two schemas) are
|
|
118
|
+
* silently skipped.
|
|
119
|
+
* - A same-name-but-different-body declaration is a genuine collision the
|
|
120
|
+
* rename pass failed to resolve; emitting it would produce an opaque
|
|
121
|
+
* `TS2300: Duplicate identifier` in the consumer's build. We fail fast at
|
|
122
|
+
* codegen with a message that names the offending identifier instead.
|
|
123
|
+
*
|
|
124
|
+
* Returns the indented declaration line to push, or `null` when it should be
|
|
125
|
+
* skipped (exact duplicate).
|
|
126
|
+
*/
|
|
127
|
+
class DeclarationCollector {
|
|
128
|
+
private readonly seenStrings = new Set<string>()
|
|
129
|
+
private readonly seenNames = new Map<string, string>()
|
|
130
|
+
|
|
131
|
+
constructor(private readonly context: string) {}
|
|
132
|
+
|
|
133
|
+
/** Returns the indented line to emit, or `null` for an exact duplicate. */
|
|
134
|
+
accept(decl: string, indentPrefix: string): string | null {
|
|
135
|
+
if (this.seenStrings.has(decl)) return null
|
|
136
|
+
const name = extractedDeclName(decl)
|
|
137
|
+
if (name != null) {
|
|
138
|
+
if (this.seenNames.has(name)) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`[ts-procedures-codegen] duplicate identifier '${name}' while emitting ${this.context}. ` +
|
|
141
|
+
`An extracted sub-type collided with another of the same name and could not be renamed. ` +
|
|
142
|
+
`This is a codegen bug — please report it with the offending schema.`,
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
this.seenNames.set(name, decl)
|
|
146
|
+
}
|
|
147
|
+
this.seenStrings.add(decl)
|
|
148
|
+
return indent(decl, indentPrefix)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
112
152
|
interface NamedType {
|
|
113
153
|
/** Short name for namespace mode (e.g., 'Params', 'Response'). */
|
|
114
154
|
shortName: string
|
|
@@ -144,7 +184,7 @@ async function formatTypes(
|
|
|
144
184
|
|
|
145
185
|
if (ctx.namespaceTypes) {
|
|
146
186
|
const nsLines: string[] = []
|
|
147
|
-
const
|
|
187
|
+
const collector = new DeclarationCollector(`namespace ${routePascal}`)
|
|
148
188
|
|
|
149
189
|
// Pre-reserve every name the route will declare itself (each shortName +
|
|
150
190
|
// any caller-supplied extras). Extracted sub-types whose names land in
|
|
@@ -165,12 +205,10 @@ async function formatTypes(
|
|
|
165
205
|
|
|
166
206
|
const result = renameExtractedTypes(rawResult, taken)
|
|
167
207
|
|
|
168
|
-
// Collect extracted sub-types (
|
|
208
|
+
// Collect extracted sub-types (dedupe exact dups; throw on real collisions)
|
|
169
209
|
for (const decl of result.declarations) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
nsLines.push(indent(decl, ' '))
|
|
173
|
-
}
|
|
210
|
+
const line = collector.accept(decl, ' ')
|
|
211
|
+
if (line != null) nsLines.push(line)
|
|
174
212
|
}
|
|
175
213
|
|
|
176
214
|
nsLines.push(` export type ${shortName} = ${result.body}`)
|
|
@@ -310,7 +348,7 @@ async function formatSubNamespace(
|
|
|
310
348
|
): Promise<{ nsBlock: string | null; refs: Record<string, string> }> {
|
|
311
349
|
const refs: Record<string, string> = {}
|
|
312
350
|
const nsLines: string[] = []
|
|
313
|
-
const
|
|
351
|
+
const collector = new DeclarationCollector(`namespace ${routePascal}.${nsName}`)
|
|
314
352
|
|
|
315
353
|
// Pre-reserve short names to prevent sub-type extraction collision
|
|
316
354
|
for (const t of types) {
|
|
@@ -327,10 +365,8 @@ async function formatSubNamespace(
|
|
|
327
365
|
const result = renameExtractedTypes(rawResult, taken)
|
|
328
366
|
|
|
329
367
|
for (const decl of result.declarations) {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
nsLines.push(indent(decl, ' '))
|
|
333
|
-
}
|
|
368
|
+
const line = collector.accept(decl, ' ')
|
|
369
|
+
if (line != null) nsLines.push(line)
|
|
334
370
|
}
|
|
335
371
|
|
|
336
372
|
nsLines.push(` export type ${shortName} = ${result.body}`)
|
|
@@ -580,17 +616,15 @@ async function emitHttpStreamRoute(route: HttpStreamRouteDoc, ctx: EmitRouteCont
|
|
|
580
616
|
{ shortName: 'Yield', schema: yieldSchema },
|
|
581
617
|
{ shortName: 'ReturnType', schema: returnSchema },
|
|
582
618
|
]
|
|
583
|
-
const
|
|
619
|
+
const collector = new DeclarationCollector(`namespace ${pascal}`)
|
|
584
620
|
for (const { shortName, schema } of directTypes) {
|
|
585
621
|
if (schema == null) continue
|
|
586
622
|
const rawResult = await jsonSchemaToExtractedTypes(schema, ctx.ajsc)
|
|
587
623
|
if (rawResult == null) continue
|
|
588
624
|
const result = renameExtractedTypes(rawResult, taken)
|
|
589
625
|
for (const decl of result.declarations) {
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
nsLines.push(indent(decl, ' '))
|
|
593
|
-
}
|
|
626
|
+
const line = collector.accept(decl, ' ')
|
|
627
|
+
if (line != null) nsLines.push(line)
|
|
594
628
|
}
|
|
595
629
|
nsLines.push(` export type ${shortName} = ${result.body}`)
|
|
596
630
|
}
|