Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,14 @@ Key observations:
2. `_update_email(request)` -- internal standalone, takes request object
3. `update_email(*, user_id, email)` -- public standalone convenience

In TypeScript, a resource holds a 64-bit integer as a `Long`. A record without
`@nexus.proto` holds it as a `number`, so nexgen refuses a resource field or
method parameter of that type that goes into such a request record, also through
a proto message inside it. It also refuses a resource with such a field that an
operation returns with no proto wire message. To fix it, bind the record or the
result type to a proto message, or use another type. nexgen also refuses a
64-bit map key in any TypeScript resource, since a `Long` can't key a `Record`.

### Name-Binding: How Resource Methods Find Operations

This is the non-obvious part. The generator does not require you to explicitly
Expand Down Expand Up @@ -823,6 +831,9 @@ enum's name and members, even when its name differs from the protobuf enum's.
Converters bridge the model and protobuf enum by number, and `@nexus.default`
continues to name a WIT case.

In TypeScript, a 64-bit integer is a `Long` in a proto-backed model, and a
`number` in other models.

### Transfer-Type Conversion

```wit
Expand Down
4 changes: 1 addition & 3 deletions advanced/samples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,7 @@ model. nexgen ignores the directives that it doesn't know yet, such as

Python imports the Stream protos from `temporalio.api`, where a `temporalio`
release with the protos will have them. Until then, the sample installs them
there from its own `temporalio-stream-protos` package. TypeScript passes a
`number` where a resource method's proto request wants a `Long`. So `tsc` skips
the service, but the test still checks its models.
there from its own `temporalio-stream-protos` package.

- WIT: [`inputs/stream-service.wit`](inputs/stream-service.wit)
- .NET: [`dotnet/wit/stream-service/`](dotnet/wit/stream-service/),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
languages = ["typescript"]

[error]
kind = "invalid-resource-method"
contains = [
"CounterService.Counter.add",
"parameter `totals` has a 64-bit map key",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package temporal:counters@1.0.0;

world system { export counter-service; }

/// @nexus.endpoint "__counter_service"
interface counter-service {
resource counter {
constructor(counter-id: string);

add: func(totals: map<s64, string>);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
languages = ["typescript"]

[error]
kind = "invalid-resource-method"
contains = [
"CounterService.Counter.add",
"field `total` goes into record `counter-service.add-request`",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package temporal:counters@1.0.0;

world system { export counter-service; }

/// @nexus.endpoint "__counter_service"
interface counter-service {
resource counter {
constructor(counter-id: string, total: s64);

add: func();
}

record add-request {
counter-id: string,
total: s64,
}

add: func(request: add-request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
languages = ["typescript"]
descriptors = "temporal"

[error]
kind = "invalid-resource-method"
contains = [
"CounterService.Counter.touch",
"parameter `seconds` goes into record `counter-service.touch-request`",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package temporal:counters@1.0.0;

world system { export counter-service; }

/// @nexus.endpoint "__counter_service"
interface counter-service {
/// @nexus.proto "google.protobuf.Timestamp"
record timestamp {
seconds: option<s64>,
nanos: option<s32>,
}

resource counter {
constructor(counter-id: string);

touch: func(seconds: option<s64>);
}

record touch-request {
counter-id: string,
at: option<timestamp>,
}

touch: func(request: touch-request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
languages = ["typescript"]

[error]
kind = "invalid-resource-method"
contains = [
"CounterService.Counter.add",
"parameter `amount` goes into record `counter-service.add-request`",
"has no `@nexus.proto`",
"A TypeScript resource holds it as a `Long`",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package temporal:counters@1.0.0;

world system { export counter-service; }

/// @nexus.endpoint "__counter_service"
interface counter-service {
resource counter {
constructor(counter-id: string);

add: func(amount: s64);
}

record add-request {
counter-id: string,
amount: s64,
}

add: func(request: add-request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
languages = ["typescript"]

[error]
kind = "invalid-resource"
contains = [
"CounterService.Counter",
"operation `GetCounter` returns it with no proto wire message",
"64-bit field `total` goes over JSON as a `number`",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package temporal:counters@1.0.0;

world system { export counter-service; }

/// @nexus.endpoint "__counter_service"
interface counter-service {
resource counter {
constructor(counter-id: string, total: s64);
}

type counter-result = own<counter>;

record get-counter-request {
counter-id: string,
}

get-counter: func(request: get-counter-request) -> counter-result;
}
28 changes: 27 additions & 1 deletion advanced/samples/typescript/tests/stream-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import * as common from "@temporalio/common";
import type { temporal } from "@temporalio/proto";
import Long from "long";
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";

import {
type ReadRecordsResponse,
StreamCloseStatus,
readRecordsRequestTransferTypeConverter,
readRecordsResponseTransferTypeConverter,
} from "../wit/stream-service/models.ts";
import { Stream } from "../wit/stream-service/resources.ts";

const startOperation = vi.hoisted(() => vi.fn());
vi.mock("@temporalio/workflow", () => ({
createNexusServiceClient: () => ({ startOperation }),
}));

describe("stream-service generated output", () => {
// No SDK has these protos yet, so proto/streams.d.ts declares them. The converters fail to
Expand Down Expand Up @@ -86,4 +92,24 @@ describe("stream-service generated output", () => {
readRecordsRequestTransferTypeConverter.toTransferType(model).stream?.id,
).toBe("stream-1");
});

test("passes 64-bit values to a resource method's request as they are", async () => {
// An offset above 2^53 loses its low digits as a `number`.
const offset = Long.fromString("1880158843699200005");
startOperation.mockResolvedValue({
result: async () => ({ records: [], nextOffset: offset.add(1) }),
});
const stream = new Stream({ id: "stream-1" }, undefined);

const page = await stream.read(offset);
await stream.read(page.nextOffset);

expect(startOperation.mock.calls.map(([, request]) => request.fromOffset)).toEqual([
offset,
offset.add(1),
]);
expect(String(startOperation.mock.calls[1][1].fromOffset)).toBe(
"1880158843699200006",
);
});
});
3 changes: 1 addition & 2 deletions advanced/samples/typescript/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,5 @@
"support/**/*.ts",
"tests/**/*.ts",
"proto/**/*.d.ts"
],
"exclude": ["wit/stream-service/**"]
]
}
5 changes: 3 additions & 2 deletions advanced/samples/typescript/wit/stream-service/resources.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Generated by nexgen latest. DO NOT EDIT!

import type * as common from "@temporalio/common";
import type Long from "long";
import type {
StreamReadStart,
StreamRef,
Expand Down Expand Up @@ -33,12 +34,12 @@ export class Stream {
}

public async read(
fromOffset?: number,
fromOffset?: Long,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think all the confusion between number and Long is likely a mistake, and not one we should compound with additional caveats and edge cases. If the overall goal is make these sepecific fields show up as longs, we could potentially just explicitly alias them in the wit.

start?: StreamReadStart,
maxRecords?: number,
wait?: common.Duration,
createWait?: common.Duration,
maxBytes?: number,
maxBytes?: Long,
): Promise<ReadRecordsResponse> {
const request = {
stream: this.stream,
Expand Down
1 change: 1 addition & 0 deletions changelog/fixed/long-lynx.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A TypeScript resource holds a 64-bit integer as a `Long`, which keeps every digit of a Stream offset. nexgen refuses such a value where it would go into a record without `@nexus.proto`, which holds it as a `number`, and a 64-bit map key in any TypeScript resource, since a `Long` can't key a `Record`.
3 changes: 3 additions & 0 deletions src/generator/dotnet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3709,6 +3709,7 @@ fn resource_method_request_model_init_expr(
let RequestPlan::Construct {
message_name,
fields,
..
} = request_plan
else {
return None;
Expand Down Expand Up @@ -4263,9 +4264,11 @@ fn with_resource_property_names(plan: &RequestPlan, type_name: &str) -> RequestP
}
RequestPlan::Construct {
message_name,
proto,
fields,
} => RequestPlan::Construct {
message_name: message_name.clone(),
proto: *proto,
fields: fields
.iter()
.map(|field| RequestPlanField {
Expand Down
1 change: 1 addition & 0 deletions src/generator/resource_plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ where
RequestPlan::Construct {
message_name,
fields,
..
} => {
let rendered_fields = fields
.iter()
Expand Down
Loading
Loading