ankka 0.6.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 (108) hide show
  1. package/README.md +30 -0
  2. package/dist/_proto/ankka/protocol/v1/agent_pb.d.ts +350 -0
  3. package/dist/_proto/ankka/protocol/v1/agent_pb.js +90 -0
  4. package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +254 -0
  5. package/dist/_proto/ankka/protocol/v1/client_pb.js +47 -0
  6. package/dist/_proto/ankka/protocol/v1/consumer_pb.d.ts +105 -0
  7. package/dist/_proto/ankka/protocol/v1/consumer_pb.js +28 -0
  8. package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +598 -0
  9. package/dist/_proto/ankka/protocol/v1/discovery_pb.js +175 -0
  10. package/dist/_proto/ankka/protocol/v1/endpoint_pb.d.ts +224 -0
  11. package/dist/_proto/ankka/protocol/v1/endpoint_pb.js +44 -0
  12. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.d.ts +212 -0
  13. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.js +47 -0
  14. package/dist/_proto/ankka/protocol/v1/key_value_pb.d.ts +154 -0
  15. package/dist/_proto/ankka/protocol/v1/key_value_pb.js +36 -0
  16. package/dist/_proto/ankka/protocol/v1/payload_pb.d.ts +276 -0
  17. package/dist/_proto/ankka/protocol/v1/payload_pb.js +110 -0
  18. package/dist/_proto/ankka/protocol/v1/timed_action_pb.d.ts +75 -0
  19. package/dist/_proto/ankka/protocol/v1/timed_action_pb.js +23 -0
  20. package/dist/_proto/ankka/protocol/v1/view_pb.d.ts +93 -0
  21. package/dist/_proto/ankka/protocol/v1/view_pb.js +23 -0
  22. package/dist/_proto/ankka/protocol/v1/workflow_pb.d.ts +298 -0
  23. package/dist/_proto/ankka/protocol/v1/workflow_pb.js +67 -0
  24. package/dist/agent.d.ts +27 -0
  25. package/dist/agent.js +36 -0
  26. package/dist/client.d.ts +92 -0
  27. package/dist/client.js +239 -0
  28. package/dist/codec.d.ts +44 -0
  29. package/dist/codec.js +215 -0
  30. package/dist/consumer.d.ts +31 -0
  31. package/dist/consumer.js +32 -0
  32. package/dist/context.d.ts +61 -0
  33. package/dist/context.js +75 -0
  34. package/dist/effects/agent.d.ts +37 -0
  35. package/dist/effects/agent.js +48 -0
  36. package/dist/effects/common.d.ts +68 -0
  37. package/dist/effects/common.js +55 -0
  38. package/dist/effects/eventSourced.d.ts +50 -0
  39. package/dist/effects/eventSourced.js +66 -0
  40. package/dist/effects/keyValue.d.ts +36 -0
  41. package/dist/effects/keyValue.js +47 -0
  42. package/dist/effects/stateless.d.ts +40 -0
  43. package/dist/effects/stateless.js +35 -0
  44. package/dist/effects/workflow.d.ts +99 -0
  45. package/dist/effects/workflow.js +83 -0
  46. package/dist/endpoint.d.ts +21 -0
  47. package/dist/endpoint.js +29 -0
  48. package/dist/eventSourcedEntity.d.ts +40 -0
  49. package/dist/eventSourcedEntity.js +66 -0
  50. package/dist/handlers.d.ts +55 -0
  51. package/dist/handlers.js +64 -0
  52. package/dist/index.d.ts +29 -0
  53. package/dist/index.js +39 -0
  54. package/dist/json.d.ts +21 -0
  55. package/dist/json.js +362 -0
  56. package/dist/keyValueEntity.d.ts +29 -0
  57. package/dist/keyValueEntity.js +52 -0
  58. package/dist/kinds.d.ts +8 -0
  59. package/dist/kinds.js +41 -0
  60. package/dist/materialise.d.ts +33 -0
  61. package/dist/materialise.js +55 -0
  62. package/dist/routes.d.ts +71 -0
  63. package/dist/routes.js +79 -0
  64. package/dist/schema.d.ts +112 -0
  65. package/dist/schema.js +200 -0
  66. package/dist/server/agent.d.ts +14 -0
  67. package/dist/server/agent.js +113 -0
  68. package/dist/server/discovery.d.ts +5 -0
  69. package/dist/server/discovery.js +21 -0
  70. package/dist/server/eventSourced.d.ts +12 -0
  71. package/dist/server/eventSourced.js +168 -0
  72. package/dist/server/http.d.ts +10 -0
  73. package/dist/server/http.js +153 -0
  74. package/dist/server/keyValue.d.ts +5 -0
  75. package/dist/server/keyValue.js +122 -0
  76. package/dist/server/payloads.d.ts +6 -0
  77. package/dist/server/payloads.js +11 -0
  78. package/dist/server/queue.d.ts +10 -0
  79. package/dist/server/queue.js +67 -0
  80. package/dist/server/server.d.ts +34 -0
  81. package/dist/server/server.js +101 -0
  82. package/dist/server/stateless.d.ts +9 -0
  83. package/dist/server/stateless.js +108 -0
  84. package/dist/server/workflow.d.ts +5 -0
  85. package/dist/server/workflow.js +244 -0
  86. package/dist/service.d.ts +147 -0
  87. package/dist/service.js +384 -0
  88. package/dist/spec.d.ts +7 -0
  89. package/dist/spec.js +106 -0
  90. package/dist/testkit/index.d.ts +3 -0
  91. package/dist/testkit/index.js +6 -0
  92. package/dist/testkit/integration.d.ts +60 -0
  93. package/dist/testkit/integration.js +284 -0
  94. package/dist/testkit/kinds.d.ts +133 -0
  95. package/dist/testkit/kinds.js +398 -0
  96. package/dist/testkit/unit.d.ts +54 -0
  97. package/dist/testkit/unit.js +224 -0
  98. package/dist/time.d.ts +73 -0
  99. package/dist/time.js +275 -0
  100. package/dist/timedAction.d.ts +20 -0
  101. package/dist/timedAction.js +28 -0
  102. package/dist/version.d.ts +1 -0
  103. package/dist/version.js +2 -0
  104. package/dist/view.d.ts +37 -0
  105. package/dist/view.js +52 -0
  106. package/dist/workflow.d.ts +33 -0
  107. package/dist/workflow.js +61 -0
  108. package/package.json +75 -0
@@ -0,0 +1,168 @@
1
+ // The event sourced conversation: one bidirectional stream per loaded instance. The sidecar sends
2
+ // `Init` (with the recovered snapshot, if any), the events after it, then commands one at a time; we fold
3
+ // the events, run each command against the state we hold, and answer with the effect as data — the
4
+ // events to persist, the retention, and a reply computed from the state *after* those events.
5
+ //
6
+ // The generator's locals are the instance's whole state. Commands are awaited in arrival order inside
7
+ // it, which is what makes "one command in flight" true without a lock. When the stream ends — the
8
+ // sidecar passivated the instance or went away, and we cannot tell which — the locals go with it.
9
+ import { create } from "@bufbuild/protobuf";
10
+ import { EventSourced, EventSourcedOutSchema, } from "../_proto/ankka/protocol/v1/event_sourced_pb.js";
11
+ import { codecFor } from "../codec.js";
12
+ import { commandContext, metadataFromProto } from "../context.js";
13
+ import { ErrorCode } from "../effects/common.js";
14
+ import { binaryCodecs } from "../codec.js";
15
+ import { errorCodeToProto } from "../kinds.js";
16
+ import { materialiseEventSourced } from "../materialise.js";
17
+ import { decodePayload, encodePayload } from "./payloads.js";
18
+ /** Retention as the protocol carries it; `undefined` when the effect asked for none. */
19
+ export function retentionToProto(retention) {
20
+ if (!retention)
21
+ return undefined;
22
+ switch (retention.kind) {
23
+ case "delete-now":
24
+ return { retention: { case: "deleteNow", value: {} } };
25
+ case "expire-after":
26
+ return { retention: { case: "expireAfter", value: { millis: BigInt(retention.after.toMillis()) } } };
27
+ }
28
+ }
29
+ function failure(commandId, error) {
30
+ return create(EventSourcedOutSchema, { message: { case: "failure", value: { commandId, error: { message: error.message, code: errorCodeToProto(error.code) } } } });
31
+ }
32
+ function messageOf(e) {
33
+ return e instanceof Error ? `${e.name}: ${e.message}` : String(e);
34
+ }
35
+ export function eventSourcedRoutes(router, ctx) {
36
+ router.service(EventSourced, {
37
+ handle: (requests) => handleEventSourced(requests, ctx),
38
+ });
39
+ }
40
+ export async function* handleEventSourced(requests, ctx) {
41
+ let registered;
42
+ let entity;
43
+ let entityId = "";
44
+ let state;
45
+ let sequence = 0n;
46
+ for await (const m of requests) {
47
+ switch (m.message.case) {
48
+ case "init": {
49
+ const init = m.message.value;
50
+ const found = ctx.registry.component(init.componentId);
51
+ if (!found || found.kind !== "event-sourced") {
52
+ yield failure(0n, { message: `no event sourced entity ${JSON.stringify(init.componentId)} is registered`, code: ErrorCode.NotFound });
53
+ return;
54
+ }
55
+ registered = found;
56
+ entityId = init.entityId;
57
+ try {
58
+ entity = new registered.cls();
59
+ entity._bindInstance(entityId);
60
+ if (init.snapshot) {
61
+ state = decodePayload(registered.stateCodec, init.snapshot.payload);
62
+ sequence = init.snapshot.sequence;
63
+ }
64
+ else {
65
+ state = entity.emptyState();
66
+ sequence = 0n;
67
+ }
68
+ }
69
+ catch (e) {
70
+ ctx.log(`ankka: ${init.componentId}/${entityId}: recovery failed: ${messageOf(e)}`);
71
+ yield failure(0n, { message: `recovery failed: ${messageOf(e)}`, code: ErrorCode.Internal });
72
+ return;
73
+ }
74
+ break;
75
+ }
76
+ case "event": {
77
+ if (!registered || !entity) {
78
+ yield failure(0n, { message: "an event arrived before Init", code: ErrorCode.Internal });
79
+ return;
80
+ }
81
+ const ev = m.message.value;
82
+ try {
83
+ state = entity.applyEvent(state, decodePayload(registered.eventCodec, ev.payload));
84
+ sequence = ev.sequence;
85
+ }
86
+ catch (e) {
87
+ ctx.log(`ankka: ${registered.id}/${entityId}: replay of event ${ev.sequence} failed: ${messageOf(e)}`);
88
+ yield failure(0n, { message: `replay of event ${ev.sequence} failed: ${messageOf(e)}`, code: ErrorCode.Internal });
89
+ return;
90
+ }
91
+ break;
92
+ }
93
+ case "command": {
94
+ if (!registered || !entity) {
95
+ yield failure(m.message.value.id, { message: "a command arrived before Init", code: ErrorCode.Internal });
96
+ return;
97
+ }
98
+ const result = await runCommand(registered, entity, entityId, state, sequence, m.message.value, ctx);
99
+ yield result.out;
100
+ if (result.newState !== undefined) {
101
+ state = result.newState.state;
102
+ sequence = result.newState.sequence;
103
+ }
104
+ break;
105
+ }
106
+ default:
107
+ break;
108
+ }
109
+ }
110
+ }
111
+ async function runCommand(registered, entity, entityId, state, sequence, cmd, ctx) {
112
+ const handler = registered.handlers.get(cmd.name);
113
+ if (!handler)
114
+ return { out: failure(cmd.id, { message: `no handler ${JSON.stringify(cmd.name)} on ${registered.id}`, code: ErrorCode.NotFound }) };
115
+ let input;
116
+ try {
117
+ input = handler.input ? decodePayload(codecFor(handler.input), cmd.payload) : undefined;
118
+ }
119
+ catch (e) {
120
+ return { out: failure(cmd.id, { message: `${registered.id}/${cmd.name}: the input could not be decoded: ${messageOf(e)}`, code: ErrorCode.BadRequest }) };
121
+ }
122
+ const metadata = metadataFromProto(cmd.metadata);
123
+ const context = commandContext(registered.id, entityId, sequence, metadata);
124
+ let effect;
125
+ entity._bindCommand(state, context, ctx.client.withMetadata(metadata));
126
+ try {
127
+ effect = (await handler.run(entity, input));
128
+ }
129
+ catch (e) {
130
+ ctx.log(`ankka: ${registered.id}/${entityId}/${cmd.name} threw: ${messageOf(e)}`);
131
+ return { out: failure(cmd.id, { message: messageOf(e), code: ErrorCode.Internal }) };
132
+ }
133
+ finally {
134
+ entity._unbindCommand();
135
+ }
136
+ if (handler.readOnly && effect?.kind !== "read-only") {
137
+ return { out: failure(cmd.id, { message: `${registered.id}/${cmd.name} is a query and returned a persisting effect`, code: ErrorCode.Internal }) };
138
+ }
139
+ try {
140
+ const m = materialiseEventSourced(effect, state, (s, e) => entity.applyEvent(s, e));
141
+ const events = m.events.map((e) => encodePayload(registered.eventCodec, e));
142
+ const replyCodec = handler.reply ? codecFor(handler.reply) : binaryCodecs.done;
143
+ const outcome = m.error
144
+ ? { outcome: { case: "error", value: { message: m.error.message, code: errorCodeToProto(m.error.code) } } }
145
+ : m.noReply
146
+ ? { outcome: { case: "noReply", value: {} } }
147
+ : { outcome: { case: "reply", value: { payload: encodePayload(replyCodec, m.reply), metadata: { entries: [] } } } };
148
+ const out = create(EventSourcedOutSchema, {
149
+ message: {
150
+ case: "reply",
151
+ value: {
152
+ commandId: cmd.id,
153
+ events,
154
+ retention: retentionToProto(m.retention),
155
+ outcome,
156
+ snapshot: cmd.snapshotRequested ? encodePayload(registered.stateCodec, m.newState) : undefined,
157
+ },
158
+ },
159
+ });
160
+ if (m.error)
161
+ return { out };
162
+ return { out, newState: { state: m.newState, sequence: sequence + BigInt(events.length) } };
163
+ }
164
+ catch (e) {
165
+ ctx.log(`ankka: ${registered.id}/${entityId}/${cmd.name}: the effect could not be applied: ${messageOf(e)}`);
166
+ return { out: failure(cmd.id, { message: messageOf(e), code: ErrorCode.Internal }) };
167
+ }
168
+ }
@@ -0,0 +1,10 @@
1
+ import type { ConnectRouter } from "@connectrpc/connect";
2
+ import { type HttpRequest, type HttpReply, type StreamFrame } from "../_proto/ankka/protocol/v1/endpoint_pb.ts";
3
+ import type { ServerContext } from "./server.ts";
4
+ export interface HttpDispatcher {
5
+ handle(req: HttpRequest): Promise<HttpReply>;
6
+ handleStream(req: HttpRequest): AsyncIterable<StreamFrame>;
7
+ }
8
+ /** The behaviour behind `Http.Handle` and `Http.HandleStream`, over the registry's endpoints. */
9
+ export declare function createHttpDispatcher(ctx: ServerContext): HttpDispatcher;
10
+ export declare function httpRoutes(router: ConnectRouter, ctx: ServerContext): void;
@@ -0,0 +1,153 @@
1
+ // HTTP requests the sidecar forwards for the routes we declared. The sidecar's router matched the route,
2
+ // applied the access rule and opened the span; here the handler runs. One endpoint instance serves every
3
+ // request, so the request itself lives in the async context (`this.request`), never on the instance.
4
+ //
5
+ // `createHttpDispatcher` is the whole behaviour; the Connect servicer and the endpoint unit testkit both
6
+ // call it, so a test cannot pass on a path the sidecar would not take.
7
+ import { create } from "@bufbuild/protobuf";
8
+ import { Http, HttpReplySchema, StreamFrameSchema } from "../_proto/ankka/protocol/v1/endpoint_pb.js";
9
+ import { codecFor } from "../codec.js";
10
+ import { Headers, Query, metadataFromProto, withRequest } from "../context.js";
11
+ import { CommandError, ErrorCode, httpStatusOf } from "../effects/common.js";
12
+ import { DecodingError } from "../json.js";
13
+ import { errorCodeToProto } from "../kinds.js";
14
+ import { HttpProblem } from "../routes.js";
15
+ import { done, resolve } from "../schema.js";
16
+ import { AsyncQueue } from "./queue.js";
17
+ const utf8 = new TextEncoder();
18
+ function textResponse(status, text) {
19
+ return create(HttpReplySchema, { message: { case: "response", value: { status, contentType: "text/plain; charset=utf-8", body: utf8.encode(text), headers: [] } } });
20
+ }
21
+ function failureReply(message) {
22
+ return create(HttpReplySchema, { message: { case: "failure", value: { commandId: 0n, error: { message, code: errorCodeToProto(ErrorCode.Internal) } } } });
23
+ }
24
+ function messageOf(e) {
25
+ return e instanceof Error ? e.message : String(e);
26
+ }
27
+ function principalOf(req) {
28
+ const p = req.principal;
29
+ if (!p)
30
+ return null;
31
+ return Object.freeze({ subject: p.subject, name: p.name ?? null, email: p.email ?? null, emailVerified: p.emailVerified, roles: Object.freeze([...p.roles]) });
32
+ }
33
+ /** A path parameter as its declared schema: strings as they are, scalars through their text codec. */
34
+ function parseParam(name, text, shape) {
35
+ if (!shape)
36
+ return text;
37
+ const r = resolve(shape);
38
+ if (r.kind === "string")
39
+ return text;
40
+ const codec = codecFor(shape);
41
+ if (codec.contentType !== "text/plain")
42
+ throw new DecodingError(`a path parameter must be a scalar, not ${r.kind}`, name);
43
+ try {
44
+ return codec.decode(utf8.encode(text));
45
+ }
46
+ catch (e) {
47
+ throw new DecodingError(`path parameter ${name}: ${messageOf(e)}`, name);
48
+ }
49
+ }
50
+ function encodeResult(route, result) {
51
+ if (result === undefined || result === done) {
52
+ return create(HttpReplySchema, { message: { case: "response", value: { status: 204, contentType: "", body: new Uint8Array(), headers: [] } } });
53
+ }
54
+ if (!route.reply)
55
+ return failureReply(`route ${route.method} ${route.template} returned a value but declares no reply shape`);
56
+ const codec = codecFor(route.reply);
57
+ const contentType = codec.contentType === "text/plain" ? "text/plain; charset=utf-8" : codec.contentType;
58
+ return create(HttpReplySchema, { message: { case: "response", value: { status: 200, contentType, body: codec.encode(result), headers: [] } } });
59
+ }
60
+ /** The behaviour behind `Http.Handle` and `Http.HandleStream`, over the registry's endpoints. */
61
+ export function createHttpDispatcher(ctx) {
62
+ const instances = new Map();
63
+ function prepare(req) {
64
+ const endpoint = ctx.registry.endpoint(req.endpointId);
65
+ const route = endpoint?.routes.get(req.routeId);
66
+ if (!endpoint || !route)
67
+ return { ok: false, reply: textResponse(404, `no route ${req.endpointId}/${req.routeId}`) };
68
+ let instance = instances.get(endpoint.id);
69
+ if (!instance) {
70
+ instance = new endpoint.cls();
71
+ instance._bindClient(ctx.client);
72
+ instances.set(endpoint.id, instance);
73
+ }
74
+ try {
75
+ const params = {};
76
+ route.paramNames.forEach((name, i) => {
77
+ params[name] = parseParam(name, req.pathArgs[i] ?? "", route.paramShapes[name]);
78
+ });
79
+ const request = Object.freeze({
80
+ params: Object.freeze(params),
81
+ query: new Query(req.query.map((p) => [p.name, p.value])),
82
+ headers: new Headers(req.headers.map((p) => [p.name, p.value])),
83
+ principal: principalOf(req),
84
+ metadata: metadataFromProto(req.metadata),
85
+ });
86
+ const body = route.body ? codecFor(route.body).decode(req.body) : undefined;
87
+ return { ok: true, prepared: { endpoint, route, instance, request, body } };
88
+ }
89
+ catch (e) {
90
+ return { ok: false, reply: textResponse(400, messageOf(e)) };
91
+ }
92
+ }
93
+ function errorReply(e, where) {
94
+ if (e instanceof HttpProblem)
95
+ return textResponse(e.status, e.message);
96
+ if (e instanceof CommandError)
97
+ return textResponse(httpStatusOf(e.code), e.message);
98
+ if (e instanceof DecodingError)
99
+ return textResponse(400, e.message);
100
+ ctx.log(`ankka: ${where} threw: ${e instanceof Error ? `${e.name}: ${e.message}` : String(e)}`);
101
+ return failureReply(messageOf(e));
102
+ }
103
+ return {
104
+ async handle(req) {
105
+ const prep = prepare(req);
106
+ if (!prep.ok)
107
+ return prep.reply;
108
+ const { route, instance, request, body } = prep.prepared;
109
+ try {
110
+ const result = await withRequest(request, () => route.run(instance, request, body));
111
+ return encodeResult(route, result);
112
+ }
113
+ catch (e) {
114
+ return errorReply(e, `${req.endpointId}/${req.routeId}`);
115
+ }
116
+ },
117
+ async *handleStream(req) {
118
+ const prep = prepare(req);
119
+ if (!prep.ok) {
120
+ const r = prep.reply.message.case === "response" ? prep.reply.message.value : undefined;
121
+ yield create(StreamFrameSchema, { frame: { case: "failed", value: { message: r ? new TextDecoder().decode(r.body) : "no route", code: errorCodeToProto(ErrorCode.NotFound) } } });
122
+ return;
123
+ }
124
+ const { route, instance, request } = prep.prepared;
125
+ // Produce inside the request's async context, consume here: the handler's `this.request` stays visible.
126
+ const queue = new AsyncQueue();
127
+ void withRequest(request, async () => {
128
+ try {
129
+ const frames = (await route.run(instance, request, undefined));
130
+ for await (const text of frames)
131
+ queue.push(create(StreamFrameSchema, { frame: { case: "text", value: text } }));
132
+ queue.push(create(StreamFrameSchema, { frame: { case: "completed", value: {} } }));
133
+ }
134
+ catch (e) {
135
+ const code = e instanceof CommandError ? e.code : ErrorCode.Internal;
136
+ ctx.log(`ankka: ${req.endpointId}/${req.routeId} (stream) threw: ${messageOf(e)}`);
137
+ queue.push(create(StreamFrameSchema, { frame: { case: "failed", value: { message: messageOf(e), code: errorCodeToProto(code) } } }));
138
+ }
139
+ finally {
140
+ queue.close();
141
+ }
142
+ });
143
+ yield* queue;
144
+ },
145
+ };
146
+ }
147
+ export function httpRoutes(router, ctx) {
148
+ const dispatcher = createHttpDispatcher(ctx);
149
+ router.service(Http, {
150
+ handle: (req) => dispatcher.handle(req),
151
+ handleStream: (req) => dispatcher.handleStream(req),
152
+ });
153
+ }
@@ -0,0 +1,5 @@
1
+ import type { ConnectRouter } from "@connectrpc/connect";
2
+ import { type KeyValueIn, type KeyValueOut } from "../_proto/ankka/protocol/v1/key_value_pb.ts";
3
+ import type { ServerContext } from "./server.ts";
4
+ export declare function keyValueRoutes(router: ConnectRouter, ctx: ServerContext): void;
5
+ export declare function handleKeyValue(requests: AsyncIterable<KeyValueIn>, ctx: ServerContext): AsyncIterable<KeyValueOut>;
@@ -0,0 +1,122 @@
1
+ // The key value conversation: the event sourced one without a fold. `Init` carries the stored state, if
2
+ // any; each command's effect names the new state to store, or nothing.
3
+ import { create } from "@bufbuild/protobuf";
4
+ import { KeyValue, KeyValueOutSchema } from "../_proto/ankka/protocol/v1/key_value_pb.js";
5
+ import { binaryCodecs, codecFor } from "../codec.js";
6
+ import { commandContext, metadataFromProto } from "../context.js";
7
+ import { ErrorCode } from "../effects/common.js";
8
+ import { errorCodeToProto } from "../kinds.js";
9
+ import { materialiseKeyValue } from "../materialise.js";
10
+ import { retentionToProto } from "./eventSourced.js";
11
+ import { decodePayload, encodePayload } from "./payloads.js";
12
+ function failure(commandId, error) {
13
+ return create(KeyValueOutSchema, { message: { case: "failure", value: { commandId, error: { message: error.message, code: errorCodeToProto(error.code) } } } });
14
+ }
15
+ function messageOf(e) {
16
+ return e instanceof Error ? `${e.name}: ${e.message}` : String(e);
17
+ }
18
+ export function keyValueRoutes(router, ctx) {
19
+ router.service(KeyValue, {
20
+ handle: (requests) => handleKeyValue(requests, ctx),
21
+ });
22
+ }
23
+ export async function* handleKeyValue(requests, ctx) {
24
+ let registered;
25
+ let entity;
26
+ let entityId = "";
27
+ let state;
28
+ for await (const m of requests) {
29
+ switch (m.message.case) {
30
+ case "init": {
31
+ const init = m.message.value;
32
+ const found = ctx.registry.of("key-value", init.componentId);
33
+ if (!found) {
34
+ yield failure(0n, { message: `no key value entity ${JSON.stringify(init.componentId)} is registered`, code: ErrorCode.NotFound });
35
+ return;
36
+ }
37
+ registered = found;
38
+ entityId = init.entityId;
39
+ try {
40
+ entity = new registered.cls();
41
+ entity._bindInstance(entityId);
42
+ state = init.state ? decodePayload(registered.stateCodec, init.state) : entity.emptyState();
43
+ }
44
+ catch (e) {
45
+ ctx.log(`ankka: ${init.componentId}/${entityId}: recovery failed: ${messageOf(e)}`);
46
+ yield failure(0n, { message: `recovery failed: ${messageOf(e)}`, code: ErrorCode.Internal });
47
+ return;
48
+ }
49
+ break;
50
+ }
51
+ case "command": {
52
+ if (!registered || !entity) {
53
+ yield failure(m.message.value.id, { message: "a command arrived before Init", code: ErrorCode.Internal });
54
+ return;
55
+ }
56
+ const result = await runCommand(registered, entity, entityId, state, m.message.value, ctx);
57
+ yield result.out;
58
+ if (result.newState !== undefined)
59
+ state = result.newState;
60
+ break;
61
+ }
62
+ default:
63
+ break;
64
+ }
65
+ }
66
+ }
67
+ async function runCommand(registered, entity, entityId, state, cmd, ctx) {
68
+ const handler = registered.handlers.get(cmd.name);
69
+ if (!handler)
70
+ return { out: failure(cmd.id, { message: `no handler ${JSON.stringify(cmd.name)} on ${registered.id}`, code: ErrorCode.NotFound }) };
71
+ let input;
72
+ try {
73
+ input = handler.input ? decodePayload(codecFor(handler.input), cmd.payload) : undefined;
74
+ }
75
+ catch (e) {
76
+ return { out: failure(cmd.id, { message: `${registered.id}/${cmd.name}: the input could not be decoded: ${messageOf(e)}`, code: ErrorCode.BadRequest }) };
77
+ }
78
+ const metadata = metadataFromProto(cmd.metadata);
79
+ const context = commandContext(registered.id, entityId, 0n, metadata);
80
+ let effect;
81
+ entity._bindCommand(state, context, ctx.client.withMetadata(metadata));
82
+ try {
83
+ effect = (await handler.run(entity, input));
84
+ }
85
+ catch (e) {
86
+ ctx.log(`ankka: ${registered.id}/${entityId}/${cmd.name} threw: ${messageOf(e)}`);
87
+ return { out: failure(cmd.id, { message: messageOf(e), code: ErrorCode.Internal }) };
88
+ }
89
+ finally {
90
+ entity._unbindCommand();
91
+ }
92
+ if (handler.readOnly && effect?.kind !== "read-only") {
93
+ return { out: failure(cmd.id, { message: `${registered.id}/${cmd.name} is a query and returned an updating effect`, code: ErrorCode.Internal }) };
94
+ }
95
+ try {
96
+ const m = materialiseKeyValue(effect, state);
97
+ const replyCodec = handler.reply ? codecFor(handler.reply) : binaryCodecs.done;
98
+ const outcome = m.error
99
+ ? { outcome: { case: "error", value: { message: m.error.message, code: errorCodeToProto(m.error.code) } } }
100
+ : m.noReply
101
+ ? { outcome: { case: "noReply", value: {} } }
102
+ : { outcome: { case: "reply", value: { payload: encodePayload(replyCodec, m.reply), metadata: { entries: [] } } } };
103
+ const out = create(KeyValueOutSchema, {
104
+ message: {
105
+ case: "reply",
106
+ value: {
107
+ commandId: cmd.id,
108
+ newState: m.changed && !m.error ? encodePayload(registered.stateCodec, m.newState) : undefined,
109
+ retention: m.error ? undefined : retentionToProto(m.retention),
110
+ outcome,
111
+ },
112
+ },
113
+ });
114
+ if (m.error)
115
+ return { out };
116
+ return { out, newState: m.newState };
117
+ }
118
+ catch (e) {
119
+ ctx.log(`ankka: ${registered.id}/${entityId}/${cmd.name}: the effect could not be applied: ${messageOf(e)}`);
120
+ return { out: failure(cmd.id, { message: messageOf(e), code: ErrorCode.Internal }) };
121
+ }
122
+ }
@@ -0,0 +1,6 @@
1
+ import { type Payload } from "../_proto/ankka/protocol/v1/payload_pb.ts";
2
+ import type { Codec } from "../codec.ts";
3
+ export declare function encodePayload<T>(codec: Codec<T>, value: T): Payload;
4
+ /** Decodes a payload with `codec`. An absent payload is the empty payload, which is what `unit` and `done` are. */
5
+ export declare function decodePayload<T>(codec: Codec<T>, payload: Payload | undefined): T;
6
+ export declare const EMPTY_PAYLOAD: Payload;
@@ -0,0 +1,11 @@
1
+ // The bridge between a codec and the protocol's `Payload`: `{ content_type, manifest, data }`.
2
+ import { create } from "@bufbuild/protobuf";
3
+ import { PayloadSchema } from "../_proto/ankka/protocol/v1/payload_pb.js";
4
+ export function encodePayload(codec, value) {
5
+ return create(PayloadSchema, { contentType: codec.contentType, manifest: codec.manifest, data: codec.encode(value) });
6
+ }
7
+ /** Decodes a payload with `codec`. An absent payload is the empty payload, which is what `unit` and `done` are. */
8
+ export function decodePayload(codec, payload) {
9
+ return codec.decode(payload?.data ?? new Uint8Array());
10
+ }
11
+ export const EMPTY_PAYLOAD = create(PayloadSchema, { contentType: "application/octet-stream", manifest: "unit", data: new Uint8Array() });
@@ -0,0 +1,10 @@
1
+ export declare class AsyncQueue<T> implements AsyncIterable<T> {
2
+ #private;
3
+ push(item: T): void;
4
+ /** Ends the iteration once the queued items are drained. */
5
+ close(): void;
6
+ /** Ends the iteration with an error once the queued items are drained. */
7
+ fail(error: unknown): void;
8
+ get closed(): boolean;
9
+ [Symbol.asyncIterator](): AsyncIterator<T>;
10
+ }
@@ -0,0 +1,67 @@
1
+ // A small async queue: producers push, one consumer iterates. Used where replies from more than one
2
+ // source leave through one stream (a workflow's commands and its running step), and where a handler's
3
+ // output must be produced inside a request's async context but consumed outside it.
4
+ export class AsyncQueue {
5
+ #items = [];
6
+ #waiting;
7
+ #closed = false;
8
+ #failure;
9
+ push(item) {
10
+ if (this.#closed)
11
+ return;
12
+ if (this.#waiting) {
13
+ const w = this.#waiting;
14
+ this.#waiting = undefined;
15
+ w({ value: item, done: false });
16
+ }
17
+ else {
18
+ this.#items.push(item);
19
+ }
20
+ }
21
+ /** Ends the iteration once the queued items are drained. */
22
+ close() {
23
+ this.#closed = true;
24
+ if (this.#waiting && this.#items.length === 0) {
25
+ const w = this.#waiting;
26
+ this.#waiting = undefined;
27
+ w({ value: undefined, done: true });
28
+ }
29
+ }
30
+ /** Ends the iteration with an error once the queued items are drained. */
31
+ fail(error) {
32
+ this.#failure = error;
33
+ this.close();
34
+ }
35
+ get closed() {
36
+ return this.#closed;
37
+ }
38
+ [Symbol.asyncIterator]() {
39
+ return {
40
+ next: () => {
41
+ if (this.#items.length > 0)
42
+ return Promise.resolve({ value: this.#items.shift(), done: false });
43
+ if (this.#closed) {
44
+ if (this.#failure !== undefined) {
45
+ const f = this.#failure;
46
+ this.#failure = undefined;
47
+ return Promise.reject(f);
48
+ }
49
+ return Promise.resolve({ value: undefined, done: true });
50
+ }
51
+ return new Promise((resolve) => {
52
+ this.#waiting = resolve;
53
+ });
54
+ },
55
+ return: () => {
56
+ this.#closed = true;
57
+ return Promise.resolve({ value: undefined, done: true });
58
+ },
59
+ // Connect's bidi client requires the request iterable to implement `throw`.
60
+ throw: (error) => {
61
+ this.#closed = true;
62
+ this.#items.length = 0;
63
+ return Promise.reject(error);
64
+ },
65
+ };
66
+ }
67
+ }
@@ -0,0 +1,34 @@
1
+ import type { Registry } from "../service.ts";
2
+ import type { ComponentClient } from "../client.ts";
3
+ export interface ServerOptions {
4
+ /** `127.0.0.1` by default; `0.0.0.0` for the testkit; anything else is refused. */
5
+ readonly host?: string;
6
+ /** `ANKKA_PROCESS_PORT`, default 9010; `0` for an ephemeral port. */
7
+ readonly port?: number;
8
+ /** The largest message accepted from the sidecar; 64 MiB by default. */
9
+ readonly readMaxBytes?: number;
10
+ readonly log?: (message: string) => void;
11
+ }
12
+ /** What every servicer needs. */
13
+ export interface ServerContext {
14
+ readonly registry: Registry;
15
+ readonly client: ComponentClient;
16
+ readonly log: (message: string) => void;
17
+ }
18
+ export declare class Server {
19
+ #private;
20
+ readonly closed: Promise<void>;
21
+ constructor(registry: Registry, client: ComponentClient, options?: ServerOptions);
22
+ /** Binds and starts serving. Resolves with the bound address, which matters when `port` was 0. */
23
+ start(): Promise<{
24
+ host: string;
25
+ port: number;
26
+ }>;
27
+ /** The bound address; throws before `start`. */
28
+ get address(): {
29
+ host: string;
30
+ port: number;
31
+ };
32
+ /** Aborts every stream, closes every session and the listener, and resolves `closed`. */
33
+ stop(): Promise<void>;
34
+ }