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.
- package/README.md +30 -0
- package/dist/_proto/ankka/protocol/v1/agent_pb.d.ts +350 -0
- package/dist/_proto/ankka/protocol/v1/agent_pb.js +90 -0
- package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +254 -0
- package/dist/_proto/ankka/protocol/v1/client_pb.js +47 -0
- package/dist/_proto/ankka/protocol/v1/consumer_pb.d.ts +105 -0
- package/dist/_proto/ankka/protocol/v1/consumer_pb.js +28 -0
- package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +598 -0
- package/dist/_proto/ankka/protocol/v1/discovery_pb.js +175 -0
- package/dist/_proto/ankka/protocol/v1/endpoint_pb.d.ts +224 -0
- package/dist/_proto/ankka/protocol/v1/endpoint_pb.js +44 -0
- package/dist/_proto/ankka/protocol/v1/event_sourced_pb.d.ts +212 -0
- package/dist/_proto/ankka/protocol/v1/event_sourced_pb.js +47 -0
- package/dist/_proto/ankka/protocol/v1/key_value_pb.d.ts +154 -0
- package/dist/_proto/ankka/protocol/v1/key_value_pb.js +36 -0
- package/dist/_proto/ankka/protocol/v1/payload_pb.d.ts +276 -0
- package/dist/_proto/ankka/protocol/v1/payload_pb.js +110 -0
- package/dist/_proto/ankka/protocol/v1/timed_action_pb.d.ts +75 -0
- package/dist/_proto/ankka/protocol/v1/timed_action_pb.js +23 -0
- package/dist/_proto/ankka/protocol/v1/view_pb.d.ts +93 -0
- package/dist/_proto/ankka/protocol/v1/view_pb.js +23 -0
- package/dist/_proto/ankka/protocol/v1/workflow_pb.d.ts +298 -0
- package/dist/_proto/ankka/protocol/v1/workflow_pb.js +67 -0
- package/dist/agent.d.ts +27 -0
- package/dist/agent.js +36 -0
- package/dist/client.d.ts +92 -0
- package/dist/client.js +239 -0
- package/dist/codec.d.ts +44 -0
- package/dist/codec.js +215 -0
- package/dist/consumer.d.ts +31 -0
- package/dist/consumer.js +32 -0
- package/dist/context.d.ts +61 -0
- package/dist/context.js +75 -0
- package/dist/effects/agent.d.ts +37 -0
- package/dist/effects/agent.js +48 -0
- package/dist/effects/common.d.ts +68 -0
- package/dist/effects/common.js +55 -0
- package/dist/effects/eventSourced.d.ts +50 -0
- package/dist/effects/eventSourced.js +66 -0
- package/dist/effects/keyValue.d.ts +36 -0
- package/dist/effects/keyValue.js +47 -0
- package/dist/effects/stateless.d.ts +40 -0
- package/dist/effects/stateless.js +35 -0
- package/dist/effects/workflow.d.ts +99 -0
- package/dist/effects/workflow.js +83 -0
- package/dist/endpoint.d.ts +21 -0
- package/dist/endpoint.js +29 -0
- package/dist/eventSourcedEntity.d.ts +40 -0
- package/dist/eventSourcedEntity.js +66 -0
- package/dist/handlers.d.ts +55 -0
- package/dist/handlers.js +64 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +39 -0
- package/dist/json.d.ts +21 -0
- package/dist/json.js +362 -0
- package/dist/keyValueEntity.d.ts +29 -0
- package/dist/keyValueEntity.js +52 -0
- package/dist/kinds.d.ts +8 -0
- package/dist/kinds.js +41 -0
- package/dist/materialise.d.ts +33 -0
- package/dist/materialise.js +55 -0
- package/dist/routes.d.ts +71 -0
- package/dist/routes.js +79 -0
- package/dist/schema.d.ts +112 -0
- package/dist/schema.js +200 -0
- package/dist/server/agent.d.ts +14 -0
- package/dist/server/agent.js +113 -0
- package/dist/server/discovery.d.ts +5 -0
- package/dist/server/discovery.js +21 -0
- package/dist/server/eventSourced.d.ts +12 -0
- package/dist/server/eventSourced.js +168 -0
- package/dist/server/http.d.ts +10 -0
- package/dist/server/http.js +153 -0
- package/dist/server/keyValue.d.ts +5 -0
- package/dist/server/keyValue.js +122 -0
- package/dist/server/payloads.d.ts +6 -0
- package/dist/server/payloads.js +11 -0
- package/dist/server/queue.d.ts +10 -0
- package/dist/server/queue.js +67 -0
- package/dist/server/server.d.ts +34 -0
- package/dist/server/server.js +101 -0
- package/dist/server/stateless.d.ts +9 -0
- package/dist/server/stateless.js +108 -0
- package/dist/server/workflow.d.ts +5 -0
- package/dist/server/workflow.js +244 -0
- package/dist/service.d.ts +147 -0
- package/dist/service.js +384 -0
- package/dist/spec.d.ts +7 -0
- package/dist/spec.js +106 -0
- package/dist/testkit/index.d.ts +3 -0
- package/dist/testkit/index.js +6 -0
- package/dist/testkit/integration.d.ts +60 -0
- package/dist/testkit/integration.js +284 -0
- package/dist/testkit/kinds.d.ts +133 -0
- package/dist/testkit/kinds.js +398 -0
- package/dist/testkit/unit.d.ts +54 -0
- package/dist/testkit/unit.js +224 -0
- package/dist/time.d.ts +73 -0
- package/dist/time.js +275 -0
- package/dist/timedAction.d.ts +20 -0
- package/dist/timedAction.js +28 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- package/dist/view.d.ts +37 -0
- package/dist/view.js +52 -0
- package/dist/workflow.d.ts +33 -0
- package/dist/workflow.js +61 -0
- package/package.json +75 -0
package/dist/service.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// The service: the explicit registry of components and endpoints. Nothing is found by scanning; a class
|
|
2
|
+
// exists to the runtime only once it is `register`ed here. Problems are collected, not thrown one at a
|
|
3
|
+
// time, and reported together before anything listens — the same rule as the Scala builder.
|
|
4
|
+
//
|
|
5
|
+
// await Ankka.service().register(ShoppingCartEntity).register(ShoppingCartEndpoint).listen()
|
|
6
|
+
import { codecFor, isCodec } from "./codec.js";
|
|
7
|
+
import { EventSourcedEntity } from "./eventSourcedEntity.js";
|
|
8
|
+
import { KeyValueEntity } from "./keyValueEntity.js";
|
|
9
|
+
import { Workflow } from "./workflow.js";
|
|
10
|
+
import { View } from "./view.js";
|
|
11
|
+
import { Consumer } from "./consumer.js";
|
|
12
|
+
import { TimedAction } from "./timedAction.js";
|
|
13
|
+
import { Agent } from "./agent.js";
|
|
14
|
+
import { Endpoint } from "./endpoint.js";
|
|
15
|
+
import { Acl as AclValues } from "./routes.js";
|
|
16
|
+
import { ComponentClient } from "./client.js";
|
|
17
|
+
import { Server } from "./server/server.js";
|
|
18
|
+
import { renderSpec } from "./spec.js";
|
|
19
|
+
/** Everything the service hosts, validated. */
|
|
20
|
+
export class Registry {
|
|
21
|
+
components;
|
|
22
|
+
endpoints;
|
|
23
|
+
constructor(components, endpoints) {
|
|
24
|
+
this.components = components;
|
|
25
|
+
this.endpoints = endpoints;
|
|
26
|
+
Object.freeze(this);
|
|
27
|
+
}
|
|
28
|
+
component(id) {
|
|
29
|
+
return this.components.get(id);
|
|
30
|
+
}
|
|
31
|
+
/** The component `id` when it is of `kind`. */
|
|
32
|
+
of(kind, id) {
|
|
33
|
+
const c = this.components.get(id);
|
|
34
|
+
return c && c.kind === kind ? c : undefined;
|
|
35
|
+
}
|
|
36
|
+
endpoint(id) {
|
|
37
|
+
return this.endpoints.get(id);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Thrown by `validate`, `spec`, `server` and `listen` with every problem the registration has. */
|
|
41
|
+
export class RegistrationError extends Error {
|
|
42
|
+
problems;
|
|
43
|
+
constructor(problems) {
|
|
44
|
+
super(`the service cannot start:\n${problems.map((p) => ` - ${p}`).join("\n")}`);
|
|
45
|
+
this.name = "RegistrationError";
|
|
46
|
+
this.problems = problems;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function extendsBase(cls, base) {
|
|
50
|
+
return typeof cls === "function" && cls.prototype instanceof base;
|
|
51
|
+
}
|
|
52
|
+
function isShape(x) {
|
|
53
|
+
return typeof x === "object" && x !== null && (typeof x.kind === "string" || isCodec(x));
|
|
54
|
+
}
|
|
55
|
+
function nameOf(cls) {
|
|
56
|
+
return typeof cls === "function" && cls.name ? cls.name : String(cls);
|
|
57
|
+
}
|
|
58
|
+
export class ServiceBuilder {
|
|
59
|
+
#classes = [];
|
|
60
|
+
#client;
|
|
61
|
+
#log;
|
|
62
|
+
constructor(options = {}) {
|
|
63
|
+
this.#client = options.client ?? new ComponentClient();
|
|
64
|
+
this.#log = options.log ?? ((m) => console.error(m));
|
|
65
|
+
}
|
|
66
|
+
/** The component client every handler receives; the integration testkit repoints it at the sidecar it started. */
|
|
67
|
+
get client() {
|
|
68
|
+
return this.#client;
|
|
69
|
+
}
|
|
70
|
+
register(cls) {
|
|
71
|
+
this.#classes.push(cls);
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
/** Validates every registration and returns the registry, or throws a `RegistrationError` naming every problem. */
|
|
75
|
+
validate() {
|
|
76
|
+
const problems = [];
|
|
77
|
+
const components = new Map();
|
|
78
|
+
const endpoints = new Map();
|
|
79
|
+
const prefixes = new Map();
|
|
80
|
+
const add = (r, cls) => {
|
|
81
|
+
if (!r)
|
|
82
|
+
return;
|
|
83
|
+
if (components.has(r.id))
|
|
84
|
+
problems.push(`two components declare the id ${JSON.stringify(r.id)} (${nameOf(components.get(r.id).cls)} and ${nameOf(cls)})`);
|
|
85
|
+
else
|
|
86
|
+
components.set(r.id, r);
|
|
87
|
+
};
|
|
88
|
+
for (const cls of this.#classes) {
|
|
89
|
+
if (extendsBase(cls, EventSourcedEntity))
|
|
90
|
+
add(registerEventSourced(cls, problems), cls);
|
|
91
|
+
else if (extendsBase(cls, KeyValueEntity))
|
|
92
|
+
add(registerKeyValue(cls, problems), cls);
|
|
93
|
+
else if (extendsBase(cls, Workflow))
|
|
94
|
+
add(registerWorkflow(cls, problems), cls);
|
|
95
|
+
else if (extendsBase(cls, View))
|
|
96
|
+
add(registerView(cls, problems), cls);
|
|
97
|
+
else if (extendsBase(cls, Consumer))
|
|
98
|
+
add(registerConsumer(cls, problems), cls);
|
|
99
|
+
else if (extendsBase(cls, TimedAction))
|
|
100
|
+
add(registerTimedAction(cls, problems), cls);
|
|
101
|
+
else if (extendsBase(cls, Agent))
|
|
102
|
+
add(registerAgent(cls, problems), cls);
|
|
103
|
+
else if (extendsBase(cls, Endpoint)) {
|
|
104
|
+
const ep = cls;
|
|
105
|
+
if (typeof ep.prefix === "string") {
|
|
106
|
+
const other = prefixes.get(ep.prefix);
|
|
107
|
+
if (other && other !== nameOf(cls))
|
|
108
|
+
problems.push(`endpoints ${other} and ${nameOf(cls)} share the prefix ${ep.prefix}`);
|
|
109
|
+
prefixes.set(ep.prefix, nameOf(cls));
|
|
110
|
+
}
|
|
111
|
+
const r = registerEndpoint(ep, problems);
|
|
112
|
+
if (r) {
|
|
113
|
+
if (endpoints.has(r.id))
|
|
114
|
+
problems.push(`two endpoints are named ${r.id}`);
|
|
115
|
+
else
|
|
116
|
+
endpoints.set(r.id, r);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
problems.push(`${nameOf(cls)} is not a component class: it must extend EventSourcedEntity, KeyValueEntity, Workflow, View, Consumer, TimedAction, Agent or Endpoint`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (problems.length > 0)
|
|
124
|
+
throw new RegistrationError(problems);
|
|
125
|
+
return new Registry(components, endpoints);
|
|
126
|
+
}
|
|
127
|
+
/** The discovery `Spec` for what is registered, without listening. */
|
|
128
|
+
spec() {
|
|
129
|
+
return renderSpec(this.validate());
|
|
130
|
+
}
|
|
131
|
+
/** An unstarted server over the validated registry, for the integration testkit. */
|
|
132
|
+
server(options = {}) {
|
|
133
|
+
return new Server(this.validate(), this.#client, { log: this.#log, ...options });
|
|
134
|
+
}
|
|
135
|
+
/** Validates, binds `ANKKA_PROCESS_PORT` (default 9010) on loopback, answers discovery, and runs until stopped. */
|
|
136
|
+
async listen(options = {}) {
|
|
137
|
+
const server = this.server(options);
|
|
138
|
+
const { host, port } = await server.start();
|
|
139
|
+
this.#log(`ankka: serving ${this.#classes.length} registered class(es) to the sidecar on ${host}:${port}`);
|
|
140
|
+
const stop = () => void server.stop();
|
|
141
|
+
process.once("SIGTERM", stop);
|
|
142
|
+
process.once("SIGINT", stop);
|
|
143
|
+
try {
|
|
144
|
+
await server.closed;
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
process.off("SIGTERM", stop);
|
|
148
|
+
process.off("SIGINT", stop);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function checker(cls, problems) {
|
|
153
|
+
let ok = true;
|
|
154
|
+
const name = nameOf(cls);
|
|
155
|
+
return {
|
|
156
|
+
fail: (m) => {
|
|
157
|
+
problems.push(`${name}: ${m}`);
|
|
158
|
+
ok = false;
|
|
159
|
+
},
|
|
160
|
+
ok: () => ok,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function requireId(cls, fail) {
|
|
164
|
+
if (typeof cls.componentId !== "string" || cls.componentId.trim() === "")
|
|
165
|
+
fail("needs a static componentId");
|
|
166
|
+
}
|
|
167
|
+
function requireShape(value, what, fail) {
|
|
168
|
+
if (!isShape(value))
|
|
169
|
+
fail(`needs a static ${what}`);
|
|
170
|
+
}
|
|
171
|
+
function registerEventSourced(cls, problems) {
|
|
172
|
+
const { fail, ok } = checker(cls, problems);
|
|
173
|
+
requireId(cls, fail);
|
|
174
|
+
requireShape(cls.state, "state: a schema or codec for its state", fail);
|
|
175
|
+
requireShape(cls.events, "events: a schema or codec for its events", fail);
|
|
176
|
+
const snapshotEvery = cls.snapshotEvery ?? 0;
|
|
177
|
+
if (!Number.isInteger(snapshotEvery) || snapshotEvery < 0)
|
|
178
|
+
fail(`snapshotEvery must be a non-negative integer, not ${String(cls.snapshotEvery)}`);
|
|
179
|
+
const handlers = collectHandlers(cls.handlers, "handlers", ["command", "query"], fail);
|
|
180
|
+
if (!ok())
|
|
181
|
+
return undefined;
|
|
182
|
+
return Object.freeze({ kind: "event-sourced", id: cls.componentId, cls, stateCodec: codecFor(cls.state), eventCodec: codecFor(cls.events), handlers, snapshotEvery });
|
|
183
|
+
}
|
|
184
|
+
function registerKeyValue(cls, problems) {
|
|
185
|
+
const { fail, ok } = checker(cls, problems);
|
|
186
|
+
requireId(cls, fail);
|
|
187
|
+
requireShape(cls.state, "state: a schema or codec for its state", fail);
|
|
188
|
+
const handlers = collectHandlers(cls.handlers, "handlers", ["command", "query"], fail);
|
|
189
|
+
if (!ok())
|
|
190
|
+
return undefined;
|
|
191
|
+
return Object.freeze({ kind: "key-value", id: cls.componentId, cls, stateCodec: codecFor(cls.state), handlers });
|
|
192
|
+
}
|
|
193
|
+
function registerWorkflow(cls, problems) {
|
|
194
|
+
const { fail, ok } = checker(cls, problems);
|
|
195
|
+
requireId(cls, fail);
|
|
196
|
+
requireShape(cls.state, "state: a schema or codec for its state", fail);
|
|
197
|
+
const handlers = collectHandlers(cls.handlers, "handlers", ["command", "query"], fail);
|
|
198
|
+
const steps = collectHandlers(cls.steps, "steps", ["step"], fail);
|
|
199
|
+
const settings = cls.settings;
|
|
200
|
+
if (settings !== undefined) {
|
|
201
|
+
if (typeof settings !== "object" || settings === null)
|
|
202
|
+
fail("settings must be a WorkflowSettings object");
|
|
203
|
+
else {
|
|
204
|
+
const check = (what, failoverTo) => {
|
|
205
|
+
if (failoverTo !== undefined && !steps.has(failoverTo))
|
|
206
|
+
fail(`${what} fails over to ${JSON.stringify(failoverTo)}, which is not a declared step`);
|
|
207
|
+
};
|
|
208
|
+
check("default recovery", settings.defaultRecovery?.failoverTo);
|
|
209
|
+
for (const [name, st] of Object.entries(settings.steps ?? {})) {
|
|
210
|
+
if (!steps.has(name))
|
|
211
|
+
fail(`settings name step ${JSON.stringify(name)}, which is not declared`);
|
|
212
|
+
check(`step ${JSON.stringify(name)}'s recovery`, st.recovery?.failoverTo);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (!ok())
|
|
217
|
+
return undefined;
|
|
218
|
+
return Object.freeze({ kind: "workflow", id: cls.componentId, cls, stateCodec: codecFor(cls.state), handlers, steps, settings });
|
|
219
|
+
}
|
|
220
|
+
function sourceOf(cls, fail) {
|
|
221
|
+
const hasSource = cls.source !== undefined;
|
|
222
|
+
const hasTopic = cls.topic !== undefined;
|
|
223
|
+
if (hasSource === hasTopic) {
|
|
224
|
+
fail("needs a static source (a component class) or a static topic, and not both");
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
if (hasTopic) {
|
|
228
|
+
if (typeof cls.topic !== "string" || cls.topic.trim() === "") {
|
|
229
|
+
fail("topic must be a non-empty string");
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
return { topic: cls.topic };
|
|
233
|
+
}
|
|
234
|
+
const src = cls.source;
|
|
235
|
+
const kind = src?.prototype?._kind;
|
|
236
|
+
if (typeof src?.componentId !== "string" || typeof kind !== "string") {
|
|
237
|
+
fail("source must be a component class (one that extends EventSourcedEntity, KeyValueEntity, ...)");
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
return { component: { kind, id: src.componentId } };
|
|
241
|
+
}
|
|
242
|
+
function registerView(cls, problems) {
|
|
243
|
+
const { fail, ok } = checker(cls, problems);
|
|
244
|
+
requireId(cls, fail);
|
|
245
|
+
const source = sourceOf(cls, fail);
|
|
246
|
+
requireShape(cls.events, "events: a schema or codec for the source's events", fail);
|
|
247
|
+
requireShape(cls.row, "row: a schema or codec for its rows", fail);
|
|
248
|
+
const queries = cls.queries ?? ["get", "all"];
|
|
249
|
+
if (!Array.isArray(queries) || queries.some((q) => typeof q !== "string" || q.trim() === ""))
|
|
250
|
+
fail("queries must be a list of names");
|
|
251
|
+
if (!ok() || !source)
|
|
252
|
+
return undefined;
|
|
253
|
+
return Object.freeze({ kind: "view", id: cls.componentId, cls, source, eventCodec: codecFor(cls.events), rowCodec: codecFor(cls.row), queries: Object.freeze([...queries]) });
|
|
254
|
+
}
|
|
255
|
+
function registerConsumer(cls, problems) {
|
|
256
|
+
const { fail, ok } = checker(cls, problems);
|
|
257
|
+
requireId(cls, fail);
|
|
258
|
+
const source = sourceOf(cls, fail);
|
|
259
|
+
requireShape(cls.message, "message: a schema or codec for the source's messages", fail);
|
|
260
|
+
if (cls.producesTo !== undefined && (typeof cls.producesTo !== "string" || cls.producesTo.trim() === ""))
|
|
261
|
+
fail("producesTo must be a topic name");
|
|
262
|
+
if (cls.producesTo !== undefined && !isShape(cls.out))
|
|
263
|
+
fail(`produces to ${JSON.stringify(cls.producesTo)} and needs a static out: the shape of what it produces`);
|
|
264
|
+
if (!ok() || !source)
|
|
265
|
+
return undefined;
|
|
266
|
+
return Object.freeze({
|
|
267
|
+
kind: "consumer",
|
|
268
|
+
id: cls.componentId,
|
|
269
|
+
cls,
|
|
270
|
+
source,
|
|
271
|
+
messageCodec: codecFor(cls.message),
|
|
272
|
+
outCodec: cls.out ? codecFor(cls.out) : undefined,
|
|
273
|
+
producesTo: cls.producesTo,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
function registerTimedAction(cls, problems) {
|
|
277
|
+
const { fail, ok } = checker(cls, problems);
|
|
278
|
+
requireId(cls, fail);
|
|
279
|
+
const actions = collectHandlers(cls.actions, "actions", ["action"], fail);
|
|
280
|
+
if (!ok())
|
|
281
|
+
return undefined;
|
|
282
|
+
return Object.freeze({ kind: "timed-action", id: cls.componentId, cls, actions });
|
|
283
|
+
}
|
|
284
|
+
function registerAgent(cls, problems) {
|
|
285
|
+
const { fail, ok } = checker(cls, problems);
|
|
286
|
+
requireId(cls, fail);
|
|
287
|
+
const handlers = collectHandlers(cls.handlers, "handlers", ["command", "stream"], fail);
|
|
288
|
+
const tools = new Map();
|
|
289
|
+
for (const [property, t] of Object.entries(cls.tools ?? {})) {
|
|
290
|
+
if (typeof t !== "object" || t === null || typeof t.name !== "string" || typeof t.run !== "function") {
|
|
291
|
+
fail(`tools.${property} is not a tool: declare it with tool(name, description, input, run)`);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (typeof t.description !== "string" || t.description.trim() === "")
|
|
295
|
+
fail(`tool ${JSON.stringify(t.name)} needs a description; the model decides by it`);
|
|
296
|
+
if (tools.has(t.name))
|
|
297
|
+
fail(`two tools declare the name ${JSON.stringify(t.name)}`);
|
|
298
|
+
tools.set(t.name, t);
|
|
299
|
+
}
|
|
300
|
+
const guardrails = new Map();
|
|
301
|
+
for (const [property, g] of Object.entries(cls.guardrails ?? {})) {
|
|
302
|
+
if (typeof g !== "object" || g === null || typeof g.name !== "string" || typeof g.check !== "function") {
|
|
303
|
+
fail(`guardrails.${property} is not a guardrail: declare it with guardrail(name, check)`);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (guardrails.has(g.name))
|
|
307
|
+
fail(`two guardrails declare the name ${JSON.stringify(g.name)}`);
|
|
308
|
+
guardrails.set(g.name, g);
|
|
309
|
+
}
|
|
310
|
+
const maxToolCallSteps = cls.maxToolCallSteps ?? 100;
|
|
311
|
+
if (!Number.isInteger(maxToolCallSteps) || maxToolCallSteps <= 0)
|
|
312
|
+
fail("maxToolCallSteps must be a positive integer");
|
|
313
|
+
if (!ok())
|
|
314
|
+
return undefined;
|
|
315
|
+
return Object.freeze({ kind: "agent", id: cls.componentId, cls, handlers, tools, guardrails, role: cls.role ?? "", maxToolCallSteps });
|
|
316
|
+
}
|
|
317
|
+
/** Collects a handler table by wire name, reporting a duplicate wire name or a kind the component cannot host. */
|
|
318
|
+
export function collectHandlers(table, what, allowed, fail) {
|
|
319
|
+
const out = new Map();
|
|
320
|
+
if (typeof table !== "object" || table === null) {
|
|
321
|
+
fail(`needs a static ${what} table`);
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
for (const [property, ref] of Object.entries(table)) {
|
|
325
|
+
if (!isHandlerRef(ref)) {
|
|
326
|
+
fail(`${what}.${property} is not a handler: declare it with command(...), query(...), step(...), action(...) or stream(...)`);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (!allowed.includes(ref.kind)) {
|
|
330
|
+
fail(`${what}.${property} is a ${ref.kind}, which does not belong in ${what}`);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (out.has(ref.name)) {
|
|
334
|
+
fail(`two ${what} declare the wire name ${JSON.stringify(ref.name)}`);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
out.set(ref.name, ref);
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
function isHandlerRef(x) {
|
|
342
|
+
return typeof x === "object" && x !== null && typeof x.name === "string" && typeof x.run === "function" && typeof x.kind === "string";
|
|
343
|
+
}
|
|
344
|
+
function registerEndpoint(cls, problems) {
|
|
345
|
+
const { fail, ok } = checker(cls, problems);
|
|
346
|
+
const name = nameOf(cls);
|
|
347
|
+
if (typeof cls.prefix !== "string" || !cls.prefix.startsWith("/") || (cls.prefix.length > 1 && cls.prefix.endsWith("/"))) {
|
|
348
|
+
fail(`needs a static prefix starting with "/" and not ending with one, not ${JSON.stringify(cls.prefix)}`);
|
|
349
|
+
}
|
|
350
|
+
if (!Object.values(AclValues).includes(cls.acl)) {
|
|
351
|
+
fail(`needs a static acl — Acl.allowAll, Acl.denyAll or Acl.authenticated. An unstated access rule is a decision nobody made`);
|
|
352
|
+
}
|
|
353
|
+
const routes = new Map();
|
|
354
|
+
const seen = new Map();
|
|
355
|
+
if (typeof cls.routes !== "object" || cls.routes === null) {
|
|
356
|
+
fail("needs a static routes table");
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
for (const [id, route] of Object.entries(cls.routes)) {
|
|
360
|
+
if (typeof route !== "object" || route === null || typeof route.template !== "string" || typeof route.run !== "function") {
|
|
361
|
+
fail(`routes.${id} is not a route: declare it with get(...), post(...), put(...), patch(...), del(...) or sse(...)`);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const r = route;
|
|
365
|
+
const key = `${r.method} ${r.template}`;
|
|
366
|
+
const other = seen.get(key);
|
|
367
|
+
if (other)
|
|
368
|
+
fail(`routes ${other} and ${id} both declare ${key}`);
|
|
369
|
+
seen.set(key, id);
|
|
370
|
+
if (r.acl !== undefined && !Object.values(AclValues).includes(r.acl))
|
|
371
|
+
fail(`routes.${id}: unknown acl ${JSON.stringify(r.acl)}`);
|
|
372
|
+
routes.set(id, r);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (!ok())
|
|
376
|
+
return undefined;
|
|
377
|
+
return Object.freeze({ id: name, cls, prefix: cls.prefix, acl: cls.acl, routes });
|
|
378
|
+
}
|
|
379
|
+
/** The entry point: `Ankka.service().register(...).listen()`. */
|
|
380
|
+
export const Ankka = Object.freeze({
|
|
381
|
+
service(options) {
|
|
382
|
+
return new ServiceBuilder(options);
|
|
383
|
+
},
|
|
384
|
+
});
|
package/dist/spec.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Endpoint_Acl, type Spec } from "./_proto/ankka/protocol/v1/discovery_pb.ts";
|
|
2
|
+
import type { Registry } from "./service.ts";
|
|
3
|
+
import { Acl } from "./routes.ts";
|
|
4
|
+
export declare const PROTOCOL_VERSION = "1.0";
|
|
5
|
+
export declare const SDK_NAME = "ankka-typescript";
|
|
6
|
+
export declare function aclToProto(acl: Acl): Endpoint_Acl;
|
|
7
|
+
export declare function renderSpec(registry: Registry): Spec;
|
package/dist/spec.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Renders the discovery Spec from the registry: what the sidecar hosts is exactly what is registered.
|
|
2
|
+
import { create } from "@bufbuild/protobuf";
|
|
3
|
+
import { Endpoint_Acl, SpecSchema } from "./_proto/ankka/protocol/v1/discovery_pb.js";
|
|
4
|
+
import { kindToProto } from "./kinds.js";
|
|
5
|
+
import { Acl } from "./routes.js";
|
|
6
|
+
import { toJsonSchema } from "./schema.js";
|
|
7
|
+
import { isCodec } from "./codec.js";
|
|
8
|
+
import { VERSION } from "./version.js";
|
|
9
|
+
export const PROTOCOL_VERSION = "1.0";
|
|
10
|
+
export const SDK_NAME = "ankka-typescript";
|
|
11
|
+
export function aclToProto(acl) {
|
|
12
|
+
switch (acl) {
|
|
13
|
+
case Acl.allowAll:
|
|
14
|
+
return Endpoint_Acl.ALLOW_ALL;
|
|
15
|
+
case Acl.denyAll:
|
|
16
|
+
return Endpoint_Acl.DENY_ALL;
|
|
17
|
+
case Acl.authenticated:
|
|
18
|
+
return Endpoint_Acl.AUTHENTICATED;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function handlerInits(handlers) {
|
|
22
|
+
return [...handlers.values()]
|
|
23
|
+
.map((h) => ({ name: h.name, readOnly: h.readOnly, streaming: h.streaming }))
|
|
24
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
25
|
+
}
|
|
26
|
+
function sourceInit(source) {
|
|
27
|
+
return "topic" in source
|
|
28
|
+
? { source: { case: "topic", value: source.topic } }
|
|
29
|
+
: { source: { case: "component", value: { kind: kindToProto(source.component.kind), id: source.component.id } } };
|
|
30
|
+
}
|
|
31
|
+
function recoveryInit(r) {
|
|
32
|
+
return { maxRetries: r.maxRetries, ...(r.failoverTo !== undefined ? { failoverTo: r.failoverTo } : {}) };
|
|
33
|
+
}
|
|
34
|
+
function settingsInit(s) {
|
|
35
|
+
return {
|
|
36
|
+
...(s.timeout ? { timeoutMillis: BigInt(s.timeout.toMillis()) } : {}),
|
|
37
|
+
...(s.defaultStepTimeout ? { defaultStepTimeoutMillis: BigInt(s.defaultStepTimeout.toMillis()) } : {}),
|
|
38
|
+
...(s.defaultRecovery ? { defaultRecovery: recoveryInit(s.defaultRecovery) } : {}),
|
|
39
|
+
steps: Object.entries(s.steps ?? {})
|
|
40
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
41
|
+
.map(([step, st]) => ({
|
|
42
|
+
step,
|
|
43
|
+
...(st.timeout ? { timeoutMillis: BigInt(st.timeout.toMillis()) } : {}),
|
|
44
|
+
...(st.recovery ? { recovery: recoveryInit(st.recovery) } : {}),
|
|
45
|
+
})),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function componentInit(c) {
|
|
49
|
+
const base = { kind: kindToProto(c.kind), id: c.id };
|
|
50
|
+
switch (c.kind) {
|
|
51
|
+
case "event-sourced":
|
|
52
|
+
return { ...base, handlers: handlerInits(c.handlers), detail: { case: "eventSourced", value: { snapshotEvery: c.snapshotEvery } } };
|
|
53
|
+
case "key-value":
|
|
54
|
+
return { ...base, handlers: handlerInits(c.handlers), detail: { case: "keyValue", value: {} } };
|
|
55
|
+
case "workflow":
|
|
56
|
+
return {
|
|
57
|
+
...base,
|
|
58
|
+
handlers: handlerInits(c.handlers),
|
|
59
|
+
detail: { case: "workflow", value: { steps: [...c.steps.keys()].sort(), ...(c.settings ? { settings: settingsInit(c.settings) } : {}) } },
|
|
60
|
+
};
|
|
61
|
+
case "view":
|
|
62
|
+
return { ...base, handlers: [], detail: { case: "view", value: { source: sourceInit(c.source), rowManifest: c.rowCodec.manifest, queries: [...c.queries] } } };
|
|
63
|
+
case "consumer":
|
|
64
|
+
return { ...base, handlers: [], detail: { case: "consumer", value: { source: sourceInit(c.source), ...(c.producesTo !== undefined ? { producesTo: c.producesTo } : {}) } } };
|
|
65
|
+
case "timed-action":
|
|
66
|
+
return { ...base, handlers: handlerInits(c.actions), detail: { case: "timedAction", value: {} } };
|
|
67
|
+
case "agent":
|
|
68
|
+
return {
|
|
69
|
+
...base,
|
|
70
|
+
handlers: handlerInits(c.handlers),
|
|
71
|
+
detail: {
|
|
72
|
+
case: "agent",
|
|
73
|
+
value: {
|
|
74
|
+
role: c.role,
|
|
75
|
+
maxToolCallSteps: c.maxToolCallSteps,
|
|
76
|
+
tools: [...c.tools.values()]
|
|
77
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
78
|
+
.map((t) => ({ name: t.name, description: t.description, inputSchemaJson: JSON.stringify(isCodec(t.input) ? { type: "object" } : toJsonSchema(t.input)) })),
|
|
79
|
+
guardrails: [...c.guardrails.keys()].sort(),
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function renderSpec(registry) {
|
|
86
|
+
const components = [...registry.components.values()].sort((a, b) => a.id.localeCompare(b.id)).map(componentInit);
|
|
87
|
+
const endpoints = [...registry.endpoints.values()].map((e) => ({
|
|
88
|
+
id: e.id,
|
|
89
|
+
prefix: e.prefix,
|
|
90
|
+
acl: aclToProto(e.acl),
|
|
91
|
+
routes: [...e.routes.entries()].map(([id, r]) => ({
|
|
92
|
+
id,
|
|
93
|
+
method: r.method,
|
|
94
|
+
template: r.template,
|
|
95
|
+
hasBody: r.body !== undefined,
|
|
96
|
+
streaming: r.streaming,
|
|
97
|
+
...(r.acl !== undefined ? { acl: aclToProto(r.acl) } : {}),
|
|
98
|
+
})),
|
|
99
|
+
}));
|
|
100
|
+
return create(SpecSchema, {
|
|
101
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
102
|
+
sdk: { name: SDK_NAME, version: VERSION },
|
|
103
|
+
components,
|
|
104
|
+
endpoints,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { EventSourcedTestKit, EndpointTestKit, Response, type RequestOptions, type Materialised } from "./unit.ts";
|
|
2
|
+
export { KeyValueTestKit, WorkflowTestKit, ViewTestKit, ConsumerTestKit, TimedActionTestKit, AgentTestKit, ScriptedModel, type WorkflowProgress, type ModelResponse, type ModelCall, } from "./kinds.ts";
|
|
3
|
+
export { AnkkaTestKit, type AnkkaTestKitOptions, type HttpResponse, type Http, type Beside } from "./integration.ts";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// `import { ... } from "ankka/testkit"`: the unit testkits (no sidecar) and the integration testkit
|
|
2
|
+
// (Postgres and the real sidecar image in Docker). The integration testkit's Docker dependencies,
|
|
3
|
+
// `testcontainers` and `@testcontainers/postgresql`, are loaded only when `AnkkaTestKit.start` runs.
|
|
4
|
+
export { EventSourcedTestKit, EndpointTestKit, Response } from "./unit.js";
|
|
5
|
+
export { KeyValueTestKit, WorkflowTestKit, ViewTestKit, ConsumerTestKit, TimedActionTestKit, AgentTestKit, ScriptedModel, } from "./kinds.js";
|
|
6
|
+
export { AnkkaTestKit } from "./integration.js";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ComponentClient } from "../client.ts";
|
|
2
|
+
import type { ServiceBuilder } from "../service.ts";
|
|
3
|
+
export interface AnkkaTestKitOptions {
|
|
4
|
+
/** The sidecar image; `$ANKKA_SIDECAR_IMAGE` or `ankka-sidecar:latest` by default. */
|
|
5
|
+
readonly image?: string;
|
|
6
|
+
/** Environment for the sidecar container: `ANKKA_MODEL_SCRIPT` scripts its model. */
|
|
7
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
8
|
+
readonly postgresImage?: string;
|
|
9
|
+
/** How long to wait for the sidecar to report healthy; 120s by default. */
|
|
10
|
+
readonly readyTimeoutMs?: number;
|
|
11
|
+
readonly log?: (message: string) => void;
|
|
12
|
+
}
|
|
13
|
+
/** What `http` answers with. */
|
|
14
|
+
export declare class HttpResponse {
|
|
15
|
+
readonly status: number;
|
|
16
|
+
readonly headers: globalThis.Headers;
|
|
17
|
+
readonly body: Uint8Array;
|
|
18
|
+
constructor(status: number, headers: globalThis.Headers, body: Uint8Array);
|
|
19
|
+
text(): string;
|
|
20
|
+
json(): unknown;
|
|
21
|
+
}
|
|
22
|
+
/** A small HTTP client over `fetch` for the sidecar's routes. */
|
|
23
|
+
export declare class Http {
|
|
24
|
+
readonly baseUrl: string;
|
|
25
|
+
constructor(baseUrl: string);
|
|
26
|
+
request(method: string, path: string, body?: unknown, headers?: Record<string, string>): Promise<HttpResponse>;
|
|
27
|
+
get(path: string, headers?: Record<string, string>): Promise<HttpResponse>;
|
|
28
|
+
post(path: string, body?: unknown, headers?: Record<string, string>): Promise<HttpResponse>;
|
|
29
|
+
put(path: string, body?: unknown, headers?: Record<string, string>): Promise<HttpResponse>;
|
|
30
|
+
delete(path: string, headers?: Record<string, string>): Promise<HttpResponse>;
|
|
31
|
+
/** The frames of an SSE response, each `data:` line decoded from its JSON. */
|
|
32
|
+
sse(path: string, headers?: Record<string, string>): Promise<string[]>;
|
|
33
|
+
}
|
|
34
|
+
/** Another service started on this kit's database, from `startBeside`. */
|
|
35
|
+
export interface Beside {
|
|
36
|
+
readonly http: Http;
|
|
37
|
+
logs(): Promise<string>;
|
|
38
|
+
stop(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
export declare class AnkkaTestKit {
|
|
41
|
+
#private;
|
|
42
|
+
/** The component client every handler in `service` uses, pointed at the sidecar's mapped callback port. */
|
|
43
|
+
readonly client: ComponentClient;
|
|
44
|
+
private constructor();
|
|
45
|
+
/** Starts Postgres, this process's server and the sidecar; a start that fails half-way stops what it started. */
|
|
46
|
+
static start(service: ServiceBuilder, options?: AnkkaTestKitOptions): Promise<AnkkaTestKit>;
|
|
47
|
+
/** The sidecar's HTTP surface: the routes this service declared. */
|
|
48
|
+
get http(): Http;
|
|
49
|
+
get baseUrl(): string;
|
|
50
|
+
/** The JDBC URL of the throwaway database. */
|
|
51
|
+
get jdbcUrl(): string;
|
|
52
|
+
/** Replaces the sidecar against the same database: every instance is gone from memory, so the next read rebuilds from the journal. */
|
|
53
|
+
restart(): Promise<void>;
|
|
54
|
+
/** Starts another image against this kit's Postgres — the Scala cart, say, to prove the journal is shared. */
|
|
55
|
+
startBeside(image: string, env?: Readonly<Record<string, string>>, httpPort?: number): Promise<Beside>;
|
|
56
|
+
/** The sidecar container's log so far. */
|
|
57
|
+
sidecarLogs(): Promise<string>;
|
|
58
|
+
stop(): Promise<void>;
|
|
59
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
60
|
+
}
|