ankka 0.10.0 → 0.12.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.
Files changed (94) hide show
  1. package/README.md +1 -0
  2. package/dist/_proto/ankka/protocol/v1/agent_pb.d.ts +35 -0
  3. package/dist/_proto/ankka/protocol/v1/agent_pb.js +8 -1
  4. package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +858 -2
  5. package/dist/_proto/ankka/protocol/v1/client_pb.js +184 -9
  6. package/dist/_proto/ankka/protocol/v1/consumer_pb.d.ts +10 -1
  7. package/dist/_proto/ankka/protocol/v1/consumer_pb.js +1 -1
  8. package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +373 -0
  9. package/dist/_proto/ankka/protocol/v1/discovery_pb.js +81 -16
  10. package/dist/_proto/ankka/protocol/v1/endpoint_pb.d.ts +210 -0
  11. package/dist/_proto/ankka/protocol/v1/endpoint_pb.js +35 -5
  12. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.d.ts +86 -0
  13. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.js +11 -1
  14. package/dist/_proto/ankka/protocol/v1/payload_pb.d.ts +61 -0
  15. package/dist/_proto/ankka/protocol/v1/payload_pb.js +18 -1
  16. package/dist/_proto/ankka/protocol/v1/view_pb.d.ts +70 -1
  17. package/dist/_proto/ankka/protocol/v1/view_pb.js +11 -1
  18. package/dist/_proto/ankka/protocol/v1/wasm_pb.d.ts +12 -0
  19. package/dist/_proto/ankka/protocol/v1/wasm_pb.js +1 -1
  20. package/dist/_proto/ankka/protocol/v1/workflow_pb.d.ts +7 -0
  21. package/dist/_proto/ankka/protocol/v1/workflow_pb.js +1 -1
  22. package/dist/agent.d.ts +15 -1
  23. package/dist/agent.js +20 -0
  24. package/dist/approvals.d.ts +47 -0
  25. package/dist/approvals.js +57 -0
  26. package/dist/autonomous.d.ts +23 -1
  27. package/dist/autonomous.js +30 -1
  28. package/dist/client.d.ts +188 -7
  29. package/dist/client.js +490 -21
  30. package/dist/consumer.d.ts +42 -3
  31. package/dist/consumer.js +35 -1
  32. package/dist/context.d.ts +29 -1
  33. package/dist/context.js +5 -2
  34. package/dist/contract.d.ts +40 -0
  35. package/dist/contract.js +60 -0
  36. package/dist/effects/common.d.ts +7 -0
  37. package/dist/effects/common.js +9 -1
  38. package/dist/effects/eventSourced.d.ts +26 -1
  39. package/dist/effects/eventSourced.js +58 -5
  40. package/dist/effects/keyed.d.ts +33 -0
  41. package/dist/effects/keyed.js +41 -0
  42. package/dist/endpoint.d.ts +10 -1
  43. package/dist/endpoint.js +20 -0
  44. package/dist/eventSourcedEntity.d.ts +8 -3
  45. package/dist/graph.d.ts +24 -2
  46. package/dist/graph.js +27 -1
  47. package/dist/handlers.d.ts +10 -2
  48. package/dist/handlers.js +6 -2
  49. package/dist/index.d.ts +15 -6
  50. package/dist/index.js +12 -3
  51. package/dist/keyedView.d.ts +59 -0
  52. package/dist/keyedView.js +73 -0
  53. package/dist/kinds.js +2 -0
  54. package/dist/mcp.d.ts +57 -0
  55. package/dist/mcp.js +64 -0
  56. package/dist/routes.d.ts +44 -4
  57. package/dist/routes.js +43 -4
  58. package/dist/server/agent.js +12 -4
  59. package/dist/server/discovery.d.ts +17 -1
  60. package/dist/server/discovery.js +182 -1
  61. package/dist/server/eventSourced.d.ts +4 -1
  62. package/dist/server/eventSourced.js +24 -6
  63. package/dist/server/http.d.ts +2 -1
  64. package/dist/server/http.js +80 -4
  65. package/dist/server/stateless.js +42 -3
  66. package/dist/server/workflow.js +5 -1
  67. package/dist/service.d.ts +46 -2
  68. package/dist/service.js +235 -15
  69. package/dist/services.d.ts +137 -0
  70. package/dist/services.js +259 -0
  71. package/dist/socket.d.ts +15 -0
  72. package/dist/socket.js +25 -0
  73. package/dist/spec.d.ts +1 -1
  74. package/dist/spec.js +94 -15
  75. package/dist/standing.d.ts +18 -0
  76. package/dist/standing.js +26 -0
  77. package/dist/startFrom.d.ts +36 -0
  78. package/dist/startFrom.js +51 -0
  79. package/dist/testkit/index.d.ts +2 -2
  80. package/dist/testkit/index.js +1 -1
  81. package/dist/testkit/integration.js +5 -0
  82. package/dist/testkit/kinds.d.ts +50 -5
  83. package/dist/testkit/kinds.js +208 -35
  84. package/dist/testkit/unit.d.ts +41 -3
  85. package/dist/testkit/unit.js +87 -10
  86. package/dist/timedAction.d.ts +17 -2
  87. package/dist/timedAction.js +33 -1
  88. package/dist/version.d.ts +1 -1
  89. package/dist/version.js +1 -1
  90. package/dist/view.d.ts +48 -1
  91. package/dist/view.js +18 -1
  92. package/dist/workflow.d.ts +18 -1
  93. package/dist/workflow.js +39 -0
  94. package/package.json +3 -2
package/dist/routes.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Socket } from "./socket.ts";
1
2
  import type { Shape } from "./codec.ts";
2
3
  import type { RequestContext } from "./context.ts";
3
4
  import type { Infer, Schema } from "./schema.ts";
@@ -12,15 +13,20 @@ export type CallerMatcher = {
12
13
  readonly kind: "anyInProject";
13
14
  } | {
14
15
  readonly kind: "self";
16
+ } | {
17
+ readonly kind: "granted";
15
18
  };
16
19
  /**
17
20
  * The callers an endpoint can name: `Acl.allowCallers(Callers.internet, Callers.service("orders"))`. A named
18
- * service is in this service's own project unless `project` names another.
21
+ * service is in this service's own project unless `project` names another. `Callers.granted` is whoever this
22
+ * service's project has granted the route to — a service of another project, or a registered machine
23
+ * (protocol 1.15); a route that does not name it can never be granted.
19
24
  */
20
25
  export declare const Callers: Readonly<{
21
26
  internet: CallerMatcher;
22
27
  anyInProject: CallerMatcher;
23
28
  self: CallerMatcher;
29
+ granted: CallerMatcher;
24
30
  service(name: string, options?: {
25
31
  readonly project?: string;
26
32
  }): CallerMatcher;
@@ -74,8 +80,10 @@ export interface RouteRef<Ep = unknown, P = unknown, B = unknown, R = unknown> {
74
80
  readonly body: Shape<B> | undefined;
75
81
  readonly reply: Shape<R> | undefined;
76
82
  readonly streaming: boolean;
83
+ /** Answered by opening a socket (protocol 1.9); `run`'s third argument is then the `Socket`. */
84
+ readonly socket: boolean;
77
85
  readonly acl: Acl | undefined;
78
- readonly run: (self: Ep, request: RequestContext<P>, body: B) => MaybePromise<R> | AsyncIterable<string>;
86
+ readonly run: (self: Ep, request: RequestContext<P>, body: B) => MaybePromise<R> | AsyncIterable<string | SseEvent>;
79
87
  }
80
88
  export declare function parseTemplate(template: string): string[];
81
89
  type NoBodyRun<Ep, T extends string, PS, R> = (self: Ep, request: RequestContext<Params<T, PS>>) => MaybePromise<R>;
@@ -100,8 +108,40 @@ export declare const del: {
100
108
  <Ep, T extends string, B, R, PS extends Readonly<Record<string, Schema>> = {}>(template: T, body: Shape<B>, reply: Shape<R>, run: BodyRun<Ep, T, PS, B, R>, options?: RouteOptions<PS>): RouteRef<Ep, Params<T, PS>, B, R>;
101
109
  <Ep, T extends string, R, PS extends Readonly<Record<string, Schema>> = {}>(template: T, reply: Shape<R>, run: NoBodyRun<Ep, T, PS, R>, options?: RouteOptions<PS>): RouteRef<Ep, Params<T, PS>, undefined, R>;
102
110
  };
103
- /** A server-sent-events route: the handler returns an `AsyncIterable<string>`, one frame per string. */
104
- export declare function sse<Ep, T extends string, PS extends Readonly<Record<string, Schema>> = {}>(template: T, run: (self: Ep, request: RequestContext<Params<T, PS>>) => AsyncIterable<string> | Promise<AsyncIterable<string>>, options?: RouteOptions<PS>): RouteRef<Ep, Params<T, PS>, undefined, never>;
111
+ /**
112
+ * A server-sent event under a name of its own, which an `sse` handler may yield beside text: an agent
113
+ * turn that stops for approval ends its stream with `sseEvent("approval", requests)`. `value` is sent as
114
+ * JSON. Needs protocol 1.11 of the runtime.
115
+ */
116
+ export interface SseEvent {
117
+ readonly kind: "sse-event";
118
+ readonly name: string;
119
+ readonly value: unknown;
120
+ }
121
+ /**
122
+ * A watch as server-sent events, for an `sse` route: each row as `row` carrying the key and the row,
123
+ * each removal as `removed`, the marker as `caught-up`, and a watch that ended with a reason as a last
124
+ * event `ended` carrying it, so a page is told why.
125
+ */
126
+ export declare function sseEvents<Row>(watch: AsyncIterable<{
127
+ readonly kind: "row";
128
+ readonly key: string;
129
+ readonly row: Row;
130
+ } | {
131
+ readonly kind: "removed";
132
+ readonly key: string;
133
+ } | {
134
+ readonly kind: "caughtUp";
135
+ }>): AsyncIterable<SseEvent>;
136
+ export declare function sseEvent(name: string, value: unknown): SseEvent;
137
+ /** A server-sent-events route: the handler returns an `AsyncIterable<string | SseEvent>`, one frame per string, and may yield an `SseEvent` among them. */
138
+ export declare function sse<Ep, T extends string, PS extends Readonly<Record<string, Schema>> = {}>(template: T, run: (self: Ep, request: RequestContext<Params<T, PS>>) => AsyncIterable<string | SseEvent> | Promise<AsyncIterable<string | SseEvent>>, options?: RouteOptions<PS>): RouteRef<Ep, Params<T, PS>, undefined, never>;
139
+ /**
140
+ * A socket route: `socket("/{room}", async (ep: Rooms, req, socket) => { for await (const t of socket) await socket.send(t) })`.
141
+ * The handler runs for as long as the socket is open, with the opening request as `req` throughout.
142
+ * Needs protocol 1.9 of the runtime.
143
+ */
144
+ export declare function socket<Ep, T extends string, PS extends Readonly<Record<string, Schema>> = {}>(template: T, run: (self: Ep, request: RequestContext<Params<T, PS>>, socket: Socket) => Promise<void>, options?: RouteOptions<PS>): RouteRef<Ep, Params<T, PS>, Socket, void>;
105
145
  /** The routes table type an endpoint declares. */
106
146
  export type RouteTable<Ep> = Readonly<Record<string, RouteRef<Ep, any, any, any>>>;
107
147
  export {};
package/dist/routes.js CHANGED
@@ -11,12 +11,15 @@
11
11
  import { isCodec } from "./codec.js";
12
12
  /**
13
13
  * The callers an endpoint can name: `Acl.allowCallers(Callers.internet, Callers.service("orders"))`. A named
14
- * service is in this service's own project unless `project` names another.
14
+ * service is in this service's own project unless `project` names another. `Callers.granted` is whoever this
15
+ * service's project has granted the route to — a service of another project, or a registered machine
16
+ * (protocol 1.15); a route that does not name it can never be granted.
15
17
  */
16
18
  export const Callers = Object.freeze({
17
19
  internet: Object.freeze({ kind: "internet" }),
18
20
  anyInProject: Object.freeze({ kind: "anyInProject" }),
19
21
  self: Object.freeze({ kind: "self" }),
22
+ granted: Object.freeze({ kind: "granted" }),
20
23
  service(name, options) {
21
24
  return Object.freeze(options?.project !== undefined ? { kind: "service", name, project: options.project } : { kind: "service", name });
22
25
  },
@@ -57,7 +60,7 @@ export function parseTemplate(template) {
57
60
  throw new TypeError(`route template ${template} repeats a parameter`);
58
61
  return names;
59
62
  }
60
- function route(method, template, body, reply, run, options, streaming) {
63
+ function route(method, template, body, reply, run, options, streaming, socket = false) {
61
64
  const paramNames = parseTemplate(template);
62
65
  if (typeof run !== "function")
63
66
  throw new TypeError(`route ${method} ${template}: the handler is not a function`);
@@ -68,7 +71,7 @@ function route(method, template, body, reply, run, options, streaming) {
68
71
  if (!paramNames.includes(name))
69
72
  throw new TypeError(`route ${method} ${template}: params names ${name}, which is not in the template`);
70
73
  }
71
- return Object.freeze({ method, template, paramNames, paramShapes, body, reply, streaming, acl: options?.acl, run });
74
+ return Object.freeze({ method, template, paramNames, paramShapes, body, reply, streaming, socket, acl: options?.acl, run });
72
75
  }
73
76
  function isShape(x) {
74
77
  return typeof x === "object" && x !== null && (typeof x.kind === "string" || isCodec(x));
@@ -96,7 +99,43 @@ export const put = withBody("PUT");
96
99
  export const patch = withBody("PATCH");
97
100
  /** A DELETE route; `delete` is a reserved word, so `del`. */
98
101
  export const del = withBody("DELETE");
99
- /** A server-sent-events route: the handler returns an `AsyncIterable<string>`, one frame per string. */
102
+ /**
103
+ * A watch as server-sent events, for an `sse` route: each row as `row` carrying the key and the row,
104
+ * each removal as `removed`, the marker as `caught-up`, and a watch that ended with a reason as a last
105
+ * event `ended` carrying it, so a page is told why.
106
+ */
107
+ export async function* sseEvents(watch) {
108
+ try {
109
+ for await (const event of watch) {
110
+ if (event.kind === "row")
111
+ yield sseEvent("row", { key: event.key, row: event.row });
112
+ else if (event.kind === "removed")
113
+ yield sseEvent("removed", { key: event.key });
114
+ else
115
+ yield sseEvent("caught-up", {});
116
+ }
117
+ }
118
+ catch (failure) {
119
+ if (failure instanceof Error && failure.name === "WatchEnded")
120
+ yield sseEvent("ended", { reason: failure.reason });
121
+ else
122
+ throw failure;
123
+ }
124
+ }
125
+ export function sseEvent(name, value) {
126
+ if (typeof name !== "string" || name === "" || /[\r\n]/.test(name))
127
+ throw new TypeError(`an event name must be one non-empty line, not ${JSON.stringify(name)}`);
128
+ return Object.freeze({ kind: "sse-event", name, value });
129
+ }
130
+ /** A server-sent-events route: the handler returns an `AsyncIterable<string | SseEvent>`, one frame per string, and may yield an `SseEvent` among them. */
100
131
  export function sse(template, run, options) {
101
132
  return route("GET", template, undefined, undefined, run, options, true);
102
133
  }
134
+ /**
135
+ * A socket route: `socket("/{room}", async (ep: Rooms, req, socket) => { for await (const t of socket) await socket.send(t) })`.
136
+ * The handler runs for as long as the socket is open, with the opening request as `req` throughout.
137
+ * Needs protocol 1.9 of the runtime.
138
+ */
139
+ export function socket(template, run, options) {
140
+ return route("GET", template, undefined, undefined, run, options, false, true);
141
+ }
@@ -84,7 +84,10 @@ export async function handleTool(req, ctx) {
84
84
  if (!registered)
85
85
  return create(ToolResultSchema, { result: { case: "error", value: `no agent ${JSON.stringify(req.componentId)} is registered` } });
86
86
  const agent = new registered.cls();
87
- agent._bind(req.sessionId, {}, ctx.client);
87
+ // What the sidecar says about the tool's run: its trace, and the agent's handler as the caller of
88
+ // whatever the tool calls. A sidecar before 1.3 sends none.
89
+ const metadata = metadataFromProto(req.metadata);
90
+ agent._bind(req.sessionId, metadata, ctx.client.withMetadata(metadata));
88
91
  try {
89
92
  return create(ToolResultSchema, { result: { case: "ok", value: await runTool(registered, agent, req.tool, req.argumentsJson) } });
90
93
  }
@@ -95,11 +98,16 @@ export async function handleTool(req, ctx) {
95
98
  }
96
99
  export async function handleGuardrail(req, ctx) {
97
100
  const registered = ctx.registry.of("agent", req.componentId) ?? ctx.registry.of("autonomous-agent", req.componentId);
98
- const guardrail = registered?.guardrails.get(req.guardrail);
101
+ // RESULT (1.11): what an MCP server's tool answered, checked by one of the agent's result guardrails,
102
+ // which are declared apart from its other guardrails.
103
+ const result = req.stage === GuardrailRequest_Stage.RESULT;
104
+ const guardrail = result ? registered?.resultGuardrails.get(req.guardrail) : registered?.guardrails.get(req.guardrail);
99
105
  if (!registered || !guardrail)
100
106
  return create(GuardrailResultSchema, { result: { case: "block", value: `no guardrail ${JSON.stringify(req.guardrail)} on ${req.componentId}` } });
101
107
  try {
102
- const reason = await guardrail.check(req.stage === GuardrailRequest_Stage.OUTPUT ? "output" : "input", req.text);
108
+ const reason = result
109
+ ? await guardrail.check(req.tool ?? "", req.text)
110
+ : await guardrail.check(req.stage === GuardrailRequest_Stage.OUTPUT ? "output" : "input", req.text);
103
111
  return reason === null || reason === undefined
104
112
  ? create(GuardrailResultSchema, { result: { case: "pass", value: {} } })
105
113
  : create(GuardrailResultSchema, { result: { case: "block", value: reason } });
@@ -114,7 +122,7 @@ async function handleAutonomousTool(registered, req, ctx) {
114
122
  if (!tool)
115
123
  return create(ToolResultSchema, { result: { case: "error", value: `no tool ${JSON.stringify(req.tool)} on ${registered.id}` } });
116
124
  const agent = new registered.cls();
117
- agent._bind(req.sessionId, ctx.client);
125
+ agent._bind(req.sessionId, ctx.client.withMetadata(metadataFromProto(req.metadata)));
118
126
  try {
119
127
  let input;
120
128
  if (isCodec(tool.input))
@@ -1,5 +1,21 @@
1
- import type { ConnectRouter } from "@connectrpc/connect";
1
+ import { type ConnectRouter } from "@connectrpc/connect";
2
2
  import { type Spec } from "../_proto/ankka/protocol/v1/discovery_pb.ts";
3
+ /**
4
+ * A sidecar older than 1.7 would ignore where a topic source starts and its version: a consumer
5
+ * declared `latest` would read everything, and a raised version would rebuild nothing. Refused,
6
+ * naming what declares them, rather than served wrong.
7
+ */
8
+ export declare function refusal(spec: Spec, sidecarProtocol: string): string | undefined;
3
9
  /** Every problem the sidecar has reported to this process, in order. */
4
10
  export declare const problems: string[];
5
11
  export declare function discoveryRoutes(router: ConnectRouter, spec: () => Spec, log?: (message: string) => void): void;
12
+ /** The protocol version whose runtimes serve socket routes. */
13
+ export declare const SOCKETS_SINCE: readonly [number, number];
14
+ /** Why `spec` cannot be declared to a runtime speaking `runtime`, or undefined. A runtime before 1.9
15
+ * does not know a socket route, and would serve it as a plain GET. */
16
+ export declare function socketRefusal(spec: Spec, runtime: string): string | undefined;
17
+ /** The protocol version whose runtimes admit granted callers. */
18
+ export declare const GRANTS_SINCE: readonly [number, number];
19
+ /** Why `spec` cannot be declared to a runtime speaking `runtime`, or undefined. A runtime before 1.15
20
+ * does not know `Callers.granted`, and would admit nobody by it. */
21
+ export declare function grantsRefusal(spec: Spec, runtime: string): string | undefined;
@@ -1,16 +1,158 @@
1
1
  // Discovery: the first conversation. The sidecar asks what we host; we answer with the Spec the
2
2
  // registry renders. `ReportError` is how the sidecar tells us why it refused to start — logged, and kept
3
3
  // so a service can expose it (the conformance reference does, at GET /conformance/problems).
4
+ import { Code, ConnectError } from "@connectrpc/connect";
4
5
  import { create } from "@bufbuild/protobuf";
5
6
  import { Discovery } from "../_proto/ankka/protocol/v1/discovery_pb.js";
6
7
  import { EmptySchema } from "../_proto/ankka/protocol/v1/payload_pb.js";
8
+ import { PROTOCOL_VERSION } from "../spec.js";
9
+ import { Kind } from "../_proto/ankka/protocol/v1/discovery_pb.js";
10
+ import { olderThan, olderThanContracts, olderThanDeclaredQueries, olderThanStartPositions, olderThanViewStreams, olderThanWorkflowSources } from "../startFrom.js";
11
+ /**
12
+ * A sidecar older than 1.7 would ignore where a topic source starts and its version: a consumer
13
+ * declared `latest` would read everything, and a raised version would rebuild nothing. Refused,
14
+ * naming what declares them, rather than served wrong.
15
+ */
16
+ export function refusal(spec, sidecarProtocol) {
17
+ return (workflowSourceRefusal(spec, sidecarProtocol) ??
18
+ startPositionRefusal(spec, sidecarProtocol) ??
19
+ declaredQueryRefusal(spec, sidecarProtocol) ??
20
+ contractRefusal(spec, sidecarProtocol) ??
21
+ watchedQueryRefusal(spec, sidecarProtocol) ??
22
+ // Last: a reason an older sidecar has too is named first, by the version it needs.
23
+ replicationRefusal(spec, sidecarProtocol));
24
+ }
25
+ /**
26
+ * A sidecar older than 1.19 would host a replicated entity at one installation and say nothing, and
27
+ * would hand a consumer given mirrored changes only the changes written here. Refused at discovery,
28
+ * naming the components and the version.
29
+ */
30
+ function replicationRefusal(spec, sidecarProtocol) {
31
+ if (!olderThan(sidecarProtocol, [1, 19]))
32
+ return undefined;
33
+ const replicated = spec.components
34
+ .filter((c) => (c.detail.case === "eventSourced" && c.detail.value.replicated) ||
35
+ (c.detail.case === "consumer" && c.detail.value.mirroredChanges))
36
+ .map((c) => c.id);
37
+ if (replicated.length === 0)
38
+ return undefined;
39
+ return (`${replicated.join(", ")} declare replication, which needs protocol 1.19: ` +
40
+ `the sidecar speaks protocol ${sidecarProtocol}, and this SDK ${PROTOCOL_VERSION}. Run a sidecar speaking 1.19 or later.`);
41
+ }
42
+ /**
43
+ * A sidecar older than 1.16 would not read a workflow as a source: a view or consumer that does would
44
+ * be handed nothing. Refused at discovery, naming them.
45
+ */
46
+ function workflowSourceRefusal(spec, sidecarProtocol) {
47
+ if (!olderThanWorkflowSources(sidecarProtocol))
48
+ return undefined;
49
+ const reading = spec.components
50
+ .filter((c) => {
51
+ const d = c.detail;
52
+ if (d.case !== "view" && d.case !== "consumer")
53
+ return false;
54
+ const sources = d.case === "view" ? [d.value.source, ...d.value.sources] : [d.value.source];
55
+ return sources.some((s) => s?.source.case === "component" && s.source.value.kind === Kind.WORKFLOW);
56
+ })
57
+ .map((c) => c.id);
58
+ if (reading.length === 0)
59
+ return undefined;
60
+ return (`${reading.join(", ")} read a workflow, which the sidecar does not know: ` +
61
+ `it speaks protocol ${sidecarProtocol}, and this SDK ${PROTOCOL_VERSION}. Run a sidecar speaking 1.16 or later.`);
62
+ }
63
+ /**
64
+ * A sidecar older than 1.14 would ignore a topic's contract, broker and parallel reading and a
65
+ * publication's contract and broker: a contract would go unchecked, a topic on another broker would be
66
+ * read from the installation's. Refused at discovery, naming the components, rather than served wrong.
67
+ */
68
+ function contractRefusal(spec, sidecarProtocol) {
69
+ if (!olderThanContracts(sidecarProtocol))
70
+ return undefined;
71
+ const declaring = spec.components
72
+ .filter((c) => {
73
+ const d = c.detail;
74
+ if (d.case !== "view" && d.case !== "consumer")
75
+ return false;
76
+ const src = d.value.source;
77
+ const onSource = src !== undefined && (src.contract !== undefined || src.broker !== undefined || src.parallel === true);
78
+ const onPublication = d.case === "consumer" && d.value.produces !== undefined && (d.value.produces.contract !== undefined || d.value.produces.broker !== undefined);
79
+ return onSource || onPublication;
80
+ })
81
+ .map((c) => c.id);
82
+ if (declaring.length === 0)
83
+ return undefined;
84
+ return (`${declaring.join(", ")} declare a topic's contract, broker or parallel reading, which the sidecar ignores: ` +
85
+ `it speaks protocol ${sidecarProtocol}, and this SDK ${PROTOCOL_VERSION}. Run a sidecar speaking 1.14 or later.`);
86
+ }
87
+ /**
88
+ * A sidecar older than 1.17 would not know that a view's query may be watched, and a watch of it would
89
+ * be refused at its first asking. Refused at discovery, naming the views.
90
+ */
91
+ function watchedQueryRefusal(spec, sidecarProtocol) {
92
+ if (!olderThanViewStreams(sidecarProtocol))
93
+ return undefined;
94
+ const watching = spec.components
95
+ .filter((c) => c.detail.case === "view" && c.detail.value.declaredQueries.some((q) => q.watched))
96
+ .map((c) => c.id);
97
+ if (watching.length === 0)
98
+ return undefined;
99
+ return (`${watching.join(", ")} declare a watched query, which the sidecar does not know: ` +
100
+ `it speaks protocol ${sidecarProtocol}, and this SDK ${PROTOCOL_VERSION}. Run a sidecar speaking 1.17 or later.`);
101
+ }
102
+ /**
103
+ * A sidecar older than 1.13 would ignore a view's declared queries, would read a keyed view as having
104
+ * no source, and would refuse a version on a view that reads an entity as applying only to a topic.
105
+ * Refused at discovery, naming the views, rather than found later or misreported.
106
+ */
107
+ function declaredQueryRefusal(spec, sidecarProtocol) {
108
+ if (!olderThanDeclaredQueries(sidecarProtocol))
109
+ return undefined;
110
+ const declaring = spec.components
111
+ .filter((c) => {
112
+ if (c.detail.case !== "view")
113
+ return false;
114
+ const v = c.detail.value;
115
+ // A declared query, a keyed view, or a version on a view that reads an entity.
116
+ return v.declaredQueries.length > 0 || v.sources.length > 0 || (v.version !== undefined && v.source?.source.case === "component");
117
+ })
118
+ .map((c) => c.id);
119
+ if (declaring.length === 0)
120
+ return undefined;
121
+ return (`${declaring.join(", ")} declare queries, several sources, or a version on a view that reads an entity, which the sidecar ignores: ` +
122
+ `it speaks protocol ${sidecarProtocol}, and this SDK ${PROTOCOL_VERSION}. Run a sidecar speaking 1.13 or later.`);
123
+ }
124
+ function startPositionRefusal(spec, sidecarProtocol) {
125
+ if (!olderThanStartPositions(sidecarProtocol))
126
+ return undefined;
127
+ const declaring = spec.components
128
+ .filter((c) => {
129
+ const d = c.detail;
130
+ if (d.case !== "view" && d.case !== "consumer")
131
+ return false;
132
+ return d.value.source?.startFrom !== undefined || d.value.version !== undefined;
133
+ })
134
+ .map((c) => c.id);
135
+ if (declaring.length === 0)
136
+ return undefined;
137
+ return (`${declaring.join(", ")} declare where a topic source starts or its version, which the sidecar ignores: ` +
138
+ `it speaks protocol ${sidecarProtocol}, and this SDK ${PROTOCOL_VERSION}. Run a sidecar speaking 1.7 or later.`);
139
+ }
7
140
  /** Every problem the sidecar has reported to this process, in order. */
8
141
  export const problems = [];
9
142
  export function discoveryRoutes(router, spec, log = console.error) {
10
143
  router.service(Discovery, {
11
144
  async discover(info) {
12
145
  log(`ankka: discovery from sidecar (protocol ${info.protocolVersion}, runtime ${info.runtimeVersion})`);
13
- return spec();
146
+ const answer = spec();
147
+ const why = refusal(answer, info.protocolVersion) ??
148
+ socketRefusal(answer, info.protocolVersion) ??
149
+ grantsRefusal(answer, info.protocolVersion);
150
+ if (why !== undefined) {
151
+ log(`ankka: ${why}`);
152
+ problems.push(why);
153
+ throw new ConnectError(why, Code.FailedPrecondition);
154
+ }
155
+ return answer;
14
156
  },
15
157
  async reportError(problem) {
16
158
  log(`ankka: the sidecar refused to start: ${problem.message}`);
@@ -19,3 +161,42 @@ export function discoveryRoutes(router, spec, log = console.error) {
19
161
  },
20
162
  });
21
163
  }
164
+ /** The protocol version whose runtimes serve socket routes. */
165
+ export const SOCKETS_SINCE = [1, 9];
166
+ /** Why `spec` cannot be declared to a runtime speaking `runtime`, or undefined. A runtime before 1.9
167
+ * does not know a socket route, and would serve it as a plain GET. */
168
+ export function socketRefusal(spec, runtime) {
169
+ const routes = spec.endpoints.flatMap((e) => e.routes.filter((r) => r.socket).map((r) => `${e.id}.${r.id}`));
170
+ if (routes.length === 0)
171
+ return undefined;
172
+ const [major, minor] = runtime.split(".").map((part) => Number.parseInt(part, 10));
173
+ const speaks = Number.isFinite(major) && Number.isFinite(minor) ? [major, minor] : [0, 0];
174
+ if (speaks[0] > SOCKETS_SINCE[0] || (speaks[0] === SOCKETS_SINCE[0] && speaks[1] >= SOCKETS_SINCE[1]))
175
+ return undefined;
176
+ return `this runtime speaks protocol ${runtime || "unknown"}; a socket route needs 1.9 (${routes.join(", ")})`;
177
+ }
178
+ /** The protocol version whose runtimes admit granted callers. */
179
+ export const GRANTS_SINCE = [1, 15];
180
+ /** Why `spec` cannot be declared to a runtime speaking `runtime`, or undefined. A runtime before 1.15
181
+ * does not know `Callers.granted`, and would admit nobody by it. */
182
+ export function grantsRefusal(spec, runtime) {
183
+ const grants = (callers) => callers.some((m) => m.kind.case === "granted");
184
+ const endpoints = spec.endpoints
185
+ .filter((e) => grants(e.allowCallers) || e.routes.some((r) => grants(r.allowCallers)))
186
+ .map((e) => e.id);
187
+ // Another project's topic is 1.15's too: an older runtime would read it as this project's.
188
+ for (const c of spec.components) {
189
+ const d = c.detail;
190
+ const sources = d.case === "view" ? [d.value.source, ...d.value.sources] : d.case === "consumer" ? [d.value.source] : [];
191
+ const publishes = d.case === "consumer" && d.value.produces?.project !== undefined;
192
+ if (sources.some((s) => s?.project !== undefined) || publishes)
193
+ endpoints.push(c.id);
194
+ }
195
+ if (endpoints.length === 0)
196
+ return undefined;
197
+ const [major, minor] = runtime.split(".").map((part) => Number.parseInt(part, 10));
198
+ const speaks = Number.isFinite(major) && Number.isFinite(minor) ? [major, minor] : [0, 0];
199
+ if (speaks[0] > GRANTS_SINCE[0] || (speaks[0] === GRANTS_SINCE[0] && speaks[1] >= GRANTS_SINCE[1]))
200
+ return undefined;
201
+ return `this runtime speaks protocol ${runtime || "unknown"}; granted callers and another project's topics need 1.15 (${endpoints.join(", ")})`;
202
+ }
@@ -1,10 +1,13 @@
1
1
  import type { ConnectRouter } from "@connectrpc/connect";
2
2
  import { type MessageInitShape } from "@bufbuild/protobuf";
3
- import { type EventSourcedIn, type EventSourcedOut } from "../_proto/ankka/protocol/v1/event_sourced_pb.ts";
3
+ import { type ReplicationChangeSchema, type EventSourcedIn, type EventSourcedOut } from "../_proto/ankka/protocol/v1/event_sourced_pb.ts";
4
4
  import type { RetentionSchema } from "../_proto/ankka/protocol/v1/payload_pb.ts";
5
5
  import { type Retention } from "../effects/common.ts";
6
+ import type { ReplicationChange } from "../effects/eventSourced.ts";
6
7
  import type { ServerContext } from "./server.ts";
7
8
  type RetentionInit = MessageInitShape<typeof RetentionSchema>;
9
+ /** A replicated entity's handover or change to its replication set, as the reply carries it (1.19). */
10
+ export declare function replicationToProto(change: ReplicationChange | undefined): MessageInitShape<typeof ReplicationChangeSchema> | undefined;
8
11
  /** Retention as the protocol carries it; `undefined` when the effect asked for none. */
9
12
  export declare function retentionToProto(retention: Retention | null): RetentionInit | undefined;
10
13
  export declare function eventSourcedRoutes(router: ConnectRouter, ctx: ServerContext): void;
@@ -9,12 +9,20 @@
9
9
  import { create } from "@bufbuild/protobuf";
10
10
  import { EventSourced, EventSourcedOutSchema, } from "../_proto/ankka/protocol/v1/event_sourced_pb.js";
11
11
  import { codecFor } from "../codec.js";
12
- import { commandContext, metadataFromProto } from "../context.js";
12
+ import { commandContext, eventContext, metadataFromProto } from "../context.js";
13
13
  import { ErrorCode } from "../effects/common.js";
14
14
  import { binaryCodecs } from "../codec.js";
15
15
  import { errorCodeToProto } from "../kinds.js";
16
16
  import { materialiseEventSourced } from "../materialise.js";
17
17
  import { decodePayload, encodePayload } from "./payloads.js";
18
+ /** A replicated entity's handover or change to its replication set, as the reply carries it (1.19). */
19
+ export function replicationToProto(change) {
20
+ if (!change)
21
+ return undefined;
22
+ return change.kind === "hand-over"
23
+ ? { change: { case: "handOverTo", value: change.to } }
24
+ : { change: { case: "set", value: { add: [...change.add], remove: [...change.remove] } } };
25
+ }
18
26
  /** Retention as the protocol carries it; `undefined` when the effect asked for none. */
19
27
  export function retentionToProto(retention) {
20
28
  if (!retention)
@@ -43,10 +51,13 @@ export async function* handleEventSourced(requests, ctx) {
43
51
  let entityId = "";
44
52
  let state;
45
53
  let sequence = 0n;
54
+ // This installation, for a replicated entity (1.19): null on a service that names none.
55
+ let here = null;
46
56
  for await (const m of requests) {
47
57
  switch (m.message.case) {
48
58
  case "init": {
49
59
  const init = m.message.value;
60
+ here = init.installation || null;
50
61
  const found = ctx.registry.component(init.componentId);
51
62
  if (!found || found.kind !== "event-sourced") {
52
63
  yield failure(0n, { message: `no event sourced entity ${JSON.stringify(init.componentId)} is registered`, code: ErrorCode.NotFound });
@@ -80,7 +91,8 @@ export async function* handleEventSourced(requests, ctx) {
80
91
  }
81
92
  const ev = m.message.value;
82
93
  try {
83
- state = entity.applyEvent(state, decodePayload(registered.eventCodec, ev.payload));
94
+ const written = ev.installation || here;
95
+ state = entity.applyEvent(state, decodePayload(registered.eventCodec, ev.payload), eventContext(written, written === here, ev.sequence));
84
96
  sequence = ev.sequence;
85
97
  }
86
98
  catch (e) {
@@ -95,7 +107,7 @@ export async function* handleEventSourced(requests, ctx) {
95
107
  yield failure(m.message.value.id, { message: "a command arrived before Init", code: ErrorCode.Internal });
96
108
  return;
97
109
  }
98
- const result = await runCommand(registered, entity, entityId, state, sequence, m.message.value, ctx);
110
+ const result = await runCommand(registered, entity, entityId, state, sequence, m.message.value, ctx, here);
99
111
  yield result.out;
100
112
  if (result.newState !== undefined) {
101
113
  state = result.newState.state;
@@ -108,7 +120,7 @@ export async function* handleEventSourced(requests, ctx) {
108
120
  }
109
121
  }
110
122
  }
111
- async function runCommand(registered, entity, entityId, state, sequence, cmd, ctx) {
123
+ async function runCommand(registered, entity, entityId, state, sequence, cmd, ctx, here = null) {
112
124
  const handler = registered.handlers.get(cmd.name);
113
125
  if (!handler)
114
126
  return { out: failure(cmd.id, { message: `no handler ${JSON.stringify(cmd.name)} on ${registered.id}`, code: ErrorCode.NotFound }) };
@@ -120,7 +132,7 @@ async function runCommand(registered, entity, entityId, state, sequence, cmd, ct
120
132
  return { out: failure(cmd.id, { message: `${registered.id}/${cmd.name}: the input could not be decoded: ${messageOf(e)}`, code: ErrorCode.BadRequest }) };
121
133
  }
122
134
  const metadata = metadataFromProto(cmd.metadata);
123
- const context = commandContext(registered.id, entityId, sequence, metadata);
135
+ const context = commandContext(registered.id, entityId, sequence, metadata, undefined, cmd.installation || null, cmd.atHome);
124
136
  let effect;
125
137
  entity._bindCommand(state, context, ctx.client.withMetadata(metadata));
126
138
  try {
@@ -137,7 +149,12 @@ async function runCommand(registered, entity, entityId, state, sequence, cmd, ct
137
149
  return { out: failure(cmd.id, { message: `${registered.id}/${cmd.name} is a query and returned a persisting effect`, code: ErrorCode.Internal }) };
138
150
  }
139
151
  try {
140
- const m = materialiseEventSourced(effect, state, (s, e) => entity.applyEvent(s, e));
152
+ let applied = sequence;
153
+ const m = materialiseEventSourced(effect, state, (s, e) => {
154
+ applied += 1n;
155
+ return entity.applyEvent(s, e, eventContext(here, true, applied));
156
+ });
157
+ const replication = !m.error && effect.kind === "persist" ? replicationToProto(effect.replication) : undefined;
141
158
  const events = m.events.map((e) => encodePayload(registered.eventCodec, e));
142
159
  const replyCodec = handler.reply ? codecFor(handler.reply) : binaryCodecs.done;
143
160
  const outcome = m.error
@@ -154,6 +171,7 @@ async function runCommand(registered, entity, entityId, state, sequence, cmd, ct
154
171
  retention: retentionToProto(m.retention),
155
172
  outcome,
156
173
  snapshot: cmd.snapshotRequested ? encodePayload(registered.stateCodec, m.newState) : undefined,
174
+ replication,
157
175
  },
158
176
  },
159
177
  });
@@ -1,9 +1,10 @@
1
1
  import type { ConnectRouter } from "@connectrpc/connect";
2
- import { type HttpRequest, type HttpReply, type StreamFrame } from "../_proto/ankka/protocol/v1/endpoint_pb.ts";
2
+ import { type HttpRequest, type HttpReply, type SocketIn, type SocketOut, type StreamFrame } from "../_proto/ankka/protocol/v1/endpoint_pb.ts";
3
3
  import type { ServerContext } from "./server.ts";
4
4
  export interface HttpDispatcher {
5
5
  handle(req: HttpRequest): Promise<HttpReply>;
6
6
  handleStream(req: HttpRequest): AsyncIterable<StreamFrame>;
7
+ handleSocket(requests: AsyncIterable<SocketIn>): AsyncIterable<SocketOut>;
7
8
  }
8
9
  /** The behaviour behind `Http.Handle` and `Http.HandleStream`, over the registry's endpoints. */
9
10
  export declare function createHttpDispatcher(ctx: ServerContext): HttpDispatcher;