Summary
When a transferable appears as a field of an operation-scoped result record (rather than as the method's return type), the Kotlin generator emits a @Serializable(with = ...) annotation pointing at a serializer object that it never generates. Serializers.kt is not emitted at all, and nothing else declares the referenced name, so the generated client does not compile.
This is not specific to third-party or unrecognized transferables — it reproduces with realtime/channel.
Reproduction
Add a fixture at native/codegen-fixtures/99-repro/spec.json:
{
"openrpc": "1.3.2",
"info": { "title": "test", "version": "1.0.0" },
"methods": [
{
"name": "api.getSession",
"params": [],
"result": {
"name": "GetSessionResult",
"schema": {
"type": "object",
"properties": {
"sessionId": { "type": "string" },
"channel": {
"x-blocks-transferable": "realtime/channel",
"x-blocks-type-args": [
{ "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"] }
]
}
},
"required": ["sessionId", "channel"]
}
}
}
]
}
Then:
cd native/kotlin && ./gradlew :codegen:regenerateFixtures --rerun-tasks
Actual result
Two files are generated — Api.kt and Servers.kt. No Serializers.kt.
Api.kt:
package com.example.app
import com.aws.blocks.kotlin.BlocksClient
import com.aws.blocks.kotlin.BlocksRequest
import com.aws.blocks.kotlin.BlocksServer
import com.aws.blocks.kotlin.json.BlocksJson
import com.aws.blocks.kotlin.realtime.RealtimeChannel
import kotlin.String
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.decodeFromJsonElement
public class Api(
private val server: BlocksServer = Servers.local,
) {
private val client: BlocksClient = BlocksClient(server)
public suspend fun getSession(): GetSession.Result {
val request = BlocksRequest(method = "api.getSession", params = emptyList(), id = BlocksRequest.nextId())
val result = client.execute(request)
return BlocksJson.decodeFromJsonElement(result)
}
public object GetSession {
@Serializable
public data class Result(
public val sessionId: String,
@Serializable(with = RealtimeChannelChannelSerializer::class)
public val channel: RealtimeChannel<Result.Channel>,
) {
@Serializable
public data class Channel(
public val text: String,
)
}
}
}
RealtimeChannelChannelSerializer is referenced unqualified on line 27, is not imported, and is not declared by any generated file.
Expected result
Either a Serializers.kt containing the referenced serializer object, or no annotation referring to one.
Cause
Two code paths disagree about which transferables exist.
The annotation is emitted while walking a record's fields, so it fires for any transferable-typed field (KotlinCodeGenerator.kt, in the record-emission path around the isTransferableType(field.type) check).
The serializer objects come from collectTransferableEntries, which walks only model.typeDefinitions:
for (typeDef in model.typeDefinitions) {
when (val t = typeDef.type) {
is ResolvedType.Record -> t.fields.forEach { visit(it.type) }
else -> {}
}
}
GetSession.Result is scoped to the operation and is emitted through the operation's nested-type path, not registered as a shared type definition. Confirmation from the same run: no Types.kt is generated for this spec, which means the type index — built from model.typeDefinitions — held no records at all. So collectTransferableEntries returns empty, and generate skips Serializers.kt because transferableEntries.isNotEmpty() is false.
Scope
Any transferable nested in an operation-scoped record. The same shape with an unrecognized tag produces the same dangling reference under the name UnknownLinkSerializer (the generated serializer name for an unrecognized tag is Unknown plus a suffix derived from the type arguments, with the tag itself omitted — so two unrecognized tags with matching type arguments would also collapse to one name once that path emits real code).
Suggested direction
Collect the serializer entries from the same traversal that emits the annotation, so the two cannot diverge — or extend the collection to include operation-scoped nested records. Keying entries by tag rather than by a name derived only from type arguments would also remove the collision noted above.
Environment
main @ 6496713695aa75706ea174172efe6eddad4920f4
- Verified via
:codegen:regenerateFixtures
Summary
When a transferable appears as a field of an operation-scoped result record (rather than as the method's return type), the Kotlin generator emits a
@Serializable(with = ...)annotation pointing at a serializer object that it never generates.Serializers.ktis not emitted at all, and nothing else declares the referenced name, so the generated client does not compile.This is not specific to third-party or unrecognized transferables — it reproduces with
realtime/channel.Reproduction
Add a fixture at
native/codegen-fixtures/99-repro/spec.json:{ "openrpc": "1.3.2", "info": { "title": "test", "version": "1.0.0" }, "methods": [ { "name": "api.getSession", "params": [], "result": { "name": "GetSessionResult", "schema": { "type": "object", "properties": { "sessionId": { "type": "string" }, "channel": { "x-blocks-transferable": "realtime/channel", "x-blocks-type-args": [ { "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"] } ] } }, "required": ["sessionId", "channel"] } } } ] }Then:
Actual result
Two files are generated —
Api.ktandServers.kt. NoSerializers.kt.Api.kt:RealtimeChannelChannelSerializeris referenced unqualified on line 27, is not imported, and is not declared by any generated file.Expected result
Either a
Serializers.ktcontaining the referenced serializer object, or no annotation referring to one.Cause
Two code paths disagree about which transferables exist.
The annotation is emitted while walking a record's fields, so it fires for any transferable-typed field (
KotlinCodeGenerator.kt, in the record-emission path around theisTransferableType(field.type)check).The serializer objects come from
collectTransferableEntries, which walks onlymodel.typeDefinitions:GetSession.Resultis scoped to the operation and is emitted through the operation's nested-type path, not registered as a shared type definition. Confirmation from the same run: noTypes.ktis generated for this spec, which means the type index — built frommodel.typeDefinitions— held no records at all. SocollectTransferableEntriesreturns empty, andgenerateskipsSerializers.ktbecausetransferableEntries.isNotEmpty()is false.Scope
Any transferable nested in an operation-scoped record. The same shape with an unrecognized tag produces the same dangling reference under the name
UnknownLinkSerializer(the generated serializer name for an unrecognized tag isUnknownplus a suffix derived from the type arguments, with the tag itself omitted — so two unrecognized tags with matching type arguments would also collapse to one name once that path emits real code).Suggested direction
Collect the serializer entries from the same traversal that emits the annotation, so the two cannot diverge — or extend the collection to include operation-scoped nested records. Keying entries by tag rather than by a name derived only from type arguments would also remove the collision noted above.
Environment
main@6496713695aa75706ea174172efe6eddad4920f4:codegen:regenerateFixtures