ankka 0.9.1 → 0.11.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 (78) hide show
  1. package/dist/_proto/ankka/protocol/v1/agent_pb.d.ts +35 -0
  2. package/dist/_proto/ankka/protocol/v1/agent_pb.js +8 -1
  3. package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +542 -2
  4. package/dist/_proto/ankka/protocol/v1/client_pb.js +107 -6
  5. package/dist/_proto/ankka/protocol/v1/consumer_pb.d.ts +52 -1
  6. package/dist/_proto/ankka/protocol/v1/consumer_pb.js +11 -1
  7. package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +330 -0
  8. package/dist/_proto/ankka/protocol/v1/discovery_pb.js +81 -16
  9. package/dist/_proto/ankka/protocol/v1/endpoint_pb.d.ts +184 -0
  10. package/dist/_proto/ankka/protocol/v1/endpoint_pb.js +26 -1
  11. package/dist/_proto/ankka/protocol/v1/view_pb.d.ts +63 -0
  12. package/dist/_proto/ankka/protocol/v1/view_pb.js +11 -1
  13. package/dist/_proto/ankka/protocol/v1/workflow_pb.d.ts +7 -0
  14. package/dist/_proto/ankka/protocol/v1/workflow_pb.js +1 -1
  15. package/dist/agent.d.ts +15 -1
  16. package/dist/agent.js +20 -0
  17. package/dist/approvals.d.ts +47 -0
  18. package/dist/approvals.js +57 -0
  19. package/dist/autonomous.d.ts +23 -1
  20. package/dist/autonomous.js +30 -1
  21. package/dist/client.d.ts +92 -4
  22. package/dist/client.js +262 -15
  23. package/dist/consumer.d.ts +40 -2
  24. package/dist/consumer.js +50 -0
  25. package/dist/context.d.ts +4 -0
  26. package/dist/contract.d.ts +36 -0
  27. package/dist/contract.js +58 -0
  28. package/dist/effects/keyed.d.ts +33 -0
  29. package/dist/effects/keyed.js +41 -0
  30. package/dist/effects/stateless.d.ts +21 -0
  31. package/dist/effects/stateless.js +19 -0
  32. package/dist/endpoint.d.ts +10 -1
  33. package/dist/endpoint.js +20 -0
  34. package/dist/graph.d.ts +171 -0
  35. package/dist/graph.js +446 -0
  36. package/dist/handlers.d.ts +10 -2
  37. package/dist/handlers.js +6 -2
  38. package/dist/index.d.ts +15 -6
  39. package/dist/index.js +13 -4
  40. package/dist/keyedView.d.ts +56 -0
  41. package/dist/keyedView.js +67 -0
  42. package/dist/mcp.d.ts +57 -0
  43. package/dist/mcp.js +64 -0
  44. package/dist/routes.d.ts +23 -3
  45. package/dist/routes.js +16 -3
  46. package/dist/server/agent.js +12 -4
  47. package/dist/server/discovery.d.ts +12 -1
  48. package/dist/server/discovery.js +95 -1
  49. package/dist/server/http.d.ts +2 -1
  50. package/dist/server/http.js +78 -4
  51. package/dist/server/stateless.js +104 -16
  52. package/dist/server/workflow.js +5 -1
  53. package/dist/service.d.ts +45 -3
  54. package/dist/service.js +234 -12
  55. package/dist/services.d.ts +137 -0
  56. package/dist/services.js +259 -0
  57. package/dist/socket.d.ts +15 -0
  58. package/dist/socket.js +25 -0
  59. package/dist/spec.d.ts +1 -1
  60. package/dist/spec.js +87 -14
  61. package/dist/startFrom.d.ts +32 -0
  62. package/dist/startFrom.js +44 -0
  63. package/dist/testkit/index.d.ts +2 -2
  64. package/dist/testkit/index.js +1 -1
  65. package/dist/testkit/integration.js +5 -0
  66. package/dist/testkit/kinds.d.ts +75 -5
  67. package/dist/testkit/kinds.js +264 -34
  68. package/dist/testkit/unit.d.ts +14 -1
  69. package/dist/testkit/unit.js +40 -3
  70. package/dist/timedAction.d.ts +17 -2
  71. package/dist/timedAction.js +33 -1
  72. package/dist/version.d.ts +1 -1
  73. package/dist/version.js +1 -1
  74. package/dist/view.d.ts +32 -0
  75. package/dist/view.js +8 -0
  76. package/dist/workflow.d.ts +18 -1
  77. package/dist/workflow.js +39 -0
  78. package/package.json +3 -2
package/dist/client.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // One channel per process, opened on first use so the integration testkit can learn the sidecar's
4
4
  // mapped port after construction. A client handed to a handler is scoped to that request's metadata,
5
5
  // so the sidecar records the call as a child span.
6
- import { createClient } from "@connectrpc/connect";
6
+ import { Code, ConnectError, createClient } from "@connectrpc/connect";
7
7
  import { createGrpcTransport } from "@connectrpc/connect-node";
8
8
  import { Client } from "./_proto/ankka/protocol/v1/client_pb.js";
9
9
  import { codecFor, jsonCodec, isCodec, textCodecs, binaryCodecs, codecForManifest } from "./codec.js";
@@ -12,7 +12,10 @@ import { decodeJsonValue, reviver } from "./json.js";
12
12
  import { errorCodeFromProto, kindToProto } from "./kinds.js";
13
13
  import { metadataToProto } from "./context.js";
14
14
  import { encodePayload, decodePayload, EMPTY_PAYLOAD } from "./server/payloads.js";
15
+ import { Duration } from "./time.js";
16
+ import { PROTOCOL_VERSION } from "./spec.js";
15
17
  import { AutonomousAgentCalls, TaskCalls, Tasks } from "./autonomous.js";
18
+ import { APPROVALS_SINCE, ApprovalAwaited, awaitingOf } from "./approvals.js";
16
19
  function stubOf(connection) {
17
20
  if (!connection.stub) {
18
21
  connection.transport = createGrpcTransport({ baseUrl: `http://${connection.address}` });
@@ -38,6 +41,43 @@ function decodeReply(shape, payload) {
38
41
  const codec = payload ? codecForManifest(payload.manifest) : undefined;
39
42
  return (codec ?? binaryCodecs.done).decode(payload?.data ?? new Uint8Array());
40
43
  }
44
+ function outcomeOf(reply, answer) {
45
+ switch (answer.result.case) {
46
+ case "reply":
47
+ return { kind: "answered", value: decodeReply(reply, answer.result.value.payload) };
48
+ case "approval":
49
+ return awaitingOf(answer.result.value);
50
+ case "error":
51
+ throw new CommandError(errorOf(answer.result.value));
52
+ default:
53
+ throw new CommandError({ message: "the sidecar answered nothing", code: "INTERNAL" });
54
+ }
55
+ }
56
+ /** Sends a decision; a runtime before approvals is reported as too old. */
57
+ async function decideOn(connection, metadata, kind, componentId, entityId, name, approvalId, decision) {
58
+ try {
59
+ return await stubOf(connection).decide({
60
+ kind: kindToProto(kind),
61
+ componentId,
62
+ entityId,
63
+ name,
64
+ approvalId,
65
+ approved: decision.approved,
66
+ by: decision.by,
67
+ ...(decision.note ? { note: decision.note } : {}),
68
+ metadata: metadataToProto(metadata),
69
+ });
70
+ }
71
+ catch (failure) {
72
+ if (failure instanceof ConnectError && failure.code === Code.Unimplemented) {
73
+ throw new CommandError({
74
+ message: `the runtime beside this process cannot record a decision on an approval request, which needs protocol ${APPROVALS_SINCE}: ${failure.rawMessage}`,
75
+ code: "INTERNAL",
76
+ });
77
+ }
78
+ throw failure;
79
+ }
80
+ }
41
81
  /** One call, ready to invoke or stream. */
42
82
  export class Invocation {
43
83
  #connection;
@@ -68,20 +108,38 @@ export class Invocation {
68
108
  metadata: metadataToProto(this.#metadata),
69
109
  };
70
110
  }
71
- /** Invokes and decodes the reply; a refusal rejects with `CommandError`. */
111
+ /**
112
+ * Invokes and decodes the reply; a refusal rejects with `CommandError`. An agent's turn that waits for a
113
+ * person rejects with `ApprovalAwaited`; `ask` answers it instead.
114
+ */
72
115
  async invoke(input) {
73
- const answer = await stubOf(this.#connection).invoke(this.#request(input));
74
- switch (answer.result.case) {
75
- case "reply":
76
- return decodeReply(this.#reply, answer.result.value.payload);
77
- case "error":
78
- throw new CommandError(errorOf(answer.result.value));
79
- default:
80
- throw new CommandError({ message: "the sidecar answered nothing", code: "INTERNAL" });
81
- }
116
+ const outcome = await this.ask(input);
117
+ if (outcome.kind === "awaiting-approval")
118
+ throw new ApprovalAwaited(outcome.requests);
119
+ return outcome.value;
120
+ }
121
+ /** Calls an agent's handler for its outcome: the model's answer, or the approval requests the turn waits on. */
122
+ async ask(input) {
123
+ return outcomeOf(this.#reply, await stubOf(this.#connection).invoke(this.#request(input)));
124
+ }
125
+ /**
126
+ * Decides one of the session's approval requests; this invocation names the handler whose turn waits.
127
+ * `by` names who decided and is required. When it was the turn's last awaited decision the turn goes on,
128
+ * and this answers as `ask` would have. A request already decided is a conflict.
129
+ */
130
+ async decide(approvalId, decision) {
131
+ return outcomeOf(this.#reply, await decideOn(this.#connection, this.#metadata, this.#kind, this.#componentId, this.#entityId, this.#name, approvalId, decision));
82
132
  }
83
- /** Invokes a streaming handler; yields tokens in order; a failure rejects with `CommandError`. */
133
+ /** Invokes a streaming handler; yields tokens in order; a failure rejects with `CommandError`, a turn that waits with `ApprovalAwaited`. */
84
134
  async *stream(input) {
135
+ for await (const part of this.streamParts(input)) {
136
+ if (typeof part !== "string")
137
+ throw new ApprovalAwaited(part.requests);
138
+ yield part;
139
+ }
140
+ }
141
+ /** As `stream`, ending with one awaiting part when the turn waits for a person. */
142
+ async *streamParts(input) {
85
143
  for await (const token of stubOf(this.#connection).invokeStream(this.#request(input))) {
86
144
  switch (token.token.case) {
87
145
  case "text":
@@ -91,6 +149,9 @@ export class Invocation {
91
149
  return;
92
150
  case "failed":
93
151
  throw new CommandError(errorOf(token.token.value));
152
+ case "approval":
153
+ yield awaitingOf(token.token.value);
154
+ return;
94
155
  }
95
156
  }
96
157
  }
@@ -128,8 +189,10 @@ export class TypedCalls {
128
189
  /** View queries: rows come back as a JSON array decoded with the row shape. */
129
190
  export class Views {
130
191
  #connection;
131
- constructor(connection) {
192
+ #metadata;
193
+ constructor(connection, metadata = {}) {
132
194
  this.#connection = connection;
195
+ this.#metadata = metadata;
133
196
  }
134
197
  async get(viewId, key, row) {
135
198
  const rows = await this.query(viewId, "get", key, row);
@@ -139,7 +202,24 @@ export class Views {
139
202
  return this.query(viewId, "all", null, row);
140
203
  }
141
204
  async query(viewId, name, key, row) {
142
- const answer = await stubOf(this.#connection).query({ viewId, name, payload: encodePayload(textCodecs.string, key ?? "") });
205
+ const answer = await stubOf(this.#connection).query({ viewId, name, payload: encodePayload(textCodecs.string, key ?? ""), metadata: metadataToProto(this.#metadata) });
206
+ return Views.#rows(answer, row);
207
+ }
208
+ /**
209
+ * The rows of one of a view's declared queries, asked by name with the values it takes. At most
210
+ * `limit` rows, 1000 when it is not given, in the statement's own order.
211
+ */
212
+ async ask(viewId, name, values, row, limit) {
213
+ const answer = await stubOf(this.#connection).query({
214
+ viewId,
215
+ name,
216
+ metadata: metadataToProto(this.#metadata),
217
+ values: { ...values },
218
+ ...(limit !== undefined ? { limit } : {}),
219
+ });
220
+ return Views.#rows(answer, row);
221
+ }
222
+ static #rows(answer, row) {
143
223
  switch (answer.result.case) {
144
224
  case "rows": {
145
225
  const text = new TextDecoder().decode(answer.result.value.data);
@@ -159,6 +239,16 @@ export class Views {
159
239
  }
160
240
  }
161
241
  }
242
+ const RECURRING_SINCE = "1.12";
243
+ const MAX_PERIOD_DAYS = 36500;
244
+ const MIN_PERIOD = Duration.ofMillis(1);
245
+ const MAX_PERIOD = Duration.ofSeconds(MAX_PERIOD_DAYS * 86400);
246
+ /** What is wrong with a recurring timer's period, if anything: the runtime's own rule. */
247
+ function periodProblem(timerId, period) {
248
+ if (Duration.compare(period, MIN_PERIOD) >= 0 && Duration.compare(period, MAX_PERIOD) <= 0)
249
+ return undefined;
250
+ return `timer '${timerId}' has a period of ${period.toString()}; a period is from 1 millisecond to ${MAX_PERIOD_DAYS} days`;
251
+ }
162
252
  export class Timers {
163
253
  #connection;
164
254
  constructor(connection) {
@@ -179,10 +269,161 @@ export class Timers {
179
269
  payload: inputPayload(t.input, input),
180
270
  });
181
271
  }
272
+ /**
273
+ * Schedules `target`, a timed action's handler, first after `delay` (zero or less is due at once) and
274
+ * then every `period` until it is cancelled or replaced. Setting it again with the same handler and
275
+ * period keeps its next due time, so a service may set its recurring timers every time it starts.
276
+ * A period outside 1 millisecond to 36,500 days is refused with `BAD_REQUEST` before anything is sent.
277
+ */
278
+ async scheduleRecurring(timerId, delay, period, target, input) {
279
+ const problem = periodProblem(timerId, period);
280
+ if (problem !== undefined)
281
+ throw new CommandError({ message: problem, code: "BAD_REQUEST" });
282
+ const t = "component" in target ? { componentId: target.component.componentId, name: target.handler.name, input: target.handler.input } : target;
283
+ const reply = await stubOf(this.#connection)
284
+ .scheduleRecurring({
285
+ timerId,
286
+ delayMillis: BigInt(delay.toMillis()),
287
+ periodMillis: BigInt(period.toMillis()),
288
+ componentId: t.componentId,
289
+ name: t.name,
290
+ payload: inputPayload(t.input, input),
291
+ })
292
+ .catch(tooOld("recurring timers", RECURRING_SINCE));
293
+ if (reply.error)
294
+ throw new CommandError(errorOf(reply.error));
295
+ }
296
+ /** Cancels the timer under `timerId`, whether it fires once or recurs. */
182
297
  async cancel(timerId) {
183
298
  await stubOf(this.#connection).cancel({ timerId });
184
299
  }
185
300
  }
301
+ // ── The secret store (protocol 1.6) ───────────────────────────────────────────
302
+ const SECRETS_SINCE = "1.6";
303
+ export const MAX_SECRET_NAME_LENGTH = 253;
304
+ export const MAX_SECRET_VALUE_BYTES = 65536;
305
+ const SECRET_NAME_RULE = `a secret's name is 1 to ${MAX_SECRET_NAME_LENGTH} characters, each a letter, a digit, '.', '_', '-' or '/'`;
306
+ const VALID_SECRET_NAME = new RegExp(`^[A-Za-z0-9._/-]{1,${MAX_SECRET_NAME_LENGTH}}$`);
307
+ /** What is wrong with a secret's name, if anything: the runtime's own rule. */
308
+ export function secretNameProblem(name) {
309
+ if (VALID_SECRET_NAME.test(name))
310
+ return undefined;
311
+ const shown = name.length > 40 ? `${name.slice(0, 40)}…` : name;
312
+ return `${SECRET_NAME_RULE}; "${shown}" is not`;
313
+ }
314
+ /** What is wrong with a secret's value, if anything. Never quotes the value. */
315
+ export function secretValueProblem(value) {
316
+ if (value === "")
317
+ return "a secret's value must not be empty";
318
+ const size = new TextEncoder().encode(value).length;
319
+ return size > MAX_SECRET_VALUE_BYTES ? `a secret's value is at most ${MAX_SECRET_VALUE_BYTES} bytes as UTF-8; this one is ${size}` : undefined;
320
+ }
321
+ /** Turns `UNIMPLEMENTED` from a runtime older than protocol `since` into an error naming both versions. */
322
+ function tooOld(what, since) {
323
+ return (failure) => {
324
+ if (failure instanceof ConnectError && failure.code === Code.Unimplemented) {
325
+ throw new CommandError({
326
+ message: `the runtime beside this process is too old for ${what}, which needs protocol ${since}; this process speaks ${PROTOCOL_VERSION}: ${failure.rawMessage}`,
327
+ code: "INTERNAL",
328
+ });
329
+ }
330
+ throw failure;
331
+ };
332
+ }
333
+ const secretsTooOld = tooOld("the secret store", SECRETS_SINCE);
334
+ /**
335
+ * The service's secret store: named text values the runtime keeps in the service's own database,
336
+ * encrypted with a key this process never sees. Offered to endpoints, workflow steps, consumers,
337
+ * timed actions and agents; an entity and a view have no `secrets` at all.
338
+ *
339
+ * Every refusal is a `CommandError` with the runtime's code: `BAD_REQUEST` for a name or a value that
340
+ * breaks its rule, `INTERNAL` when the service has no secret key or a value was kept with another
341
+ * one, `UNAVAILABLE` when the database cannot be reached.
342
+ */
343
+ export class Secrets {
344
+ #client;
345
+ /** The store reached through `client`'s sidecar; with none, every call throws. */
346
+ constructor(client) {
347
+ this.#client = client;
348
+ }
349
+ #stub() {
350
+ const connection = this.#client ? connectionOf.get(this.#client) : undefined;
351
+ if (!connection)
352
+ throw new Error("this secret store is not connected to a runtime; a unit test passes an InMemorySecrets");
353
+ return stubOf(connection);
354
+ }
355
+ /** Keeps `value` under `name`, replacing what was there. */
356
+ async put(name, value) {
357
+ const reply = await this.#stub().putSecret({ name, value }).catch(secretsTooOld);
358
+ if (reply.error)
359
+ throw new CommandError(errorOf(reply.error));
360
+ }
361
+ /** The value kept under `name`, or `undefined` when there is none. Never an empty string. */
362
+ async get(name) {
363
+ const reply = await this.#stub().getSecret({ name }).catch(secretsTooOld);
364
+ switch (reply.result.case) {
365
+ case "value":
366
+ return reply.result.value;
367
+ case "absent":
368
+ return undefined;
369
+ case "error":
370
+ throw new CommandError(errorOf(reply.result.value));
371
+ default:
372
+ throw new CommandError({ message: "the runtime answered a secret's read with nothing", code: "INTERNAL" });
373
+ }
374
+ }
375
+ /** Removes what is kept under `name`. Removing nothing is not an error. */
376
+ async delete(name) {
377
+ const reply = await this.#stub().deleteSecret({ name }).catch(secretsTooOld);
378
+ if (reply.error)
379
+ throw new CommandError(errorOf(reply.error));
380
+ }
381
+ }
382
+ /** A secret store for unit tests: a map, applying the runtime's rules with its words. */
383
+ export class InMemorySecrets extends Secrets {
384
+ values = new Map();
385
+ async put(name, value) {
386
+ const problem = secretNameProblem(name) ?? secretValueProblem(value);
387
+ if (problem)
388
+ throw new CommandError({ message: problem, code: "BAD_REQUEST" });
389
+ this.values.set(name, value);
390
+ }
391
+ async get(name) {
392
+ const problem = secretNameProblem(name);
393
+ if (problem)
394
+ throw new CommandError({ message: problem, code: "BAD_REQUEST" });
395
+ return this.values.get(name);
396
+ }
397
+ async delete(name) {
398
+ const problem = secretNameProblem(name);
399
+ if (problem)
400
+ throw new CommandError({ message: problem, code: "BAD_REQUEST" });
401
+ this.values.delete(name);
402
+ }
403
+ }
404
+ /** A store for unit tests whose every call throws: the unit kit's default, beside `noClient()`. */
405
+ export function noSecrets() {
406
+ return new Secrets(undefined);
407
+ }
408
+ const connectionOf = new WeakMap();
409
+ const metadataOf = new WeakMap();
410
+ /**
411
+ * @internal What a call to another service is sent through: `client`'s sidecar, and the metadata of
412
+ * the handler `client` was scoped to. Not a member of the client, which an entity holds too.
413
+ */
414
+ export function channelOf(client) {
415
+ const connection = connectionOf.get(client);
416
+ if (!connection)
417
+ return undefined;
418
+ return { stub: () => stubOf(connection), metadata: metadataOf.get(client) ?? {} };
419
+ }
420
+ /**
421
+ * @internal The secret store reached through `client`'s sidecar. Not a member of the client, which
422
+ * an entity holds too: only the component kinds that may have a store call this.
423
+ */
424
+ export function secretsFor(client) {
425
+ return new Secrets(client);
426
+ }
186
427
  export class ComponentClient {
187
428
  #connection;
188
429
  #metadata;
@@ -191,8 +432,10 @@ export class ComponentClient {
191
432
  constructor(address = process.env.ANKKA_SIDECAR_ADDRESS ?? "127.0.0.1:9011", connection, metadata = {}) {
192
433
  this.#connection = connection ?? { address, transport: undefined, stub: undefined };
193
434
  this.#metadata = metadata;
194
- this.views = new Views(this.#connection);
435
+ this.views = new Views(this.#connection, metadata);
195
436
  this.timers = new Timers(this.#connection);
437
+ connectionOf.set(this, this.#connection);
438
+ metadataOf.set(this, metadata);
196
439
  }
197
440
  /** Points every client sharing this connection at a new sidecar address (the integration testkit's mapped port). */
198
441
  reconnect(address) {
@@ -253,6 +496,10 @@ export class ComponentClient {
253
496
  throw new CommandError({ message: "the sidecar answered nothing", code: "INTERNAL" });
254
497
  }
255
498
  }
499
+ /** @internal A decision on an approval request; a refusal rejects with `CommandError`. */
500
+ async _decide(kind, componentId, entityId, name, approvalId, decision) {
501
+ outcomeOf(undefined, await decideOn(this.#connection, this.#metadata, kind, componentId, entityId, name, approvalId, decision));
502
+ }
256
503
  /** @internal A streaming call whose tokens are text. */
257
504
  async *_rawStream(kind, componentId, entityId, name) {
258
505
  const request = { kind: kindToProto(kind), componentId, entityId, name, payload: EMPTY_PAYLOAD, metadata: metadataToProto(this.#metadata) };
@@ -1,15 +1,42 @@
1
+ import type { StartFrom } from "./startFrom.ts";
2
+ import type { Contract, Publication } from "./contract.ts";
1
3
  import type { Shape } from "./codec.ts";
2
4
  import type { Metadata } from "./effects/common.ts";
3
- import type { ComponentClient, ComponentRef } from "./client.ts";
5
+ import { type ComponentClient, type ComponentRef, type Secrets } from "./client.ts";
4
6
  import { ConsumerEffects, type ConsumerEffect } from "./effects/stateless.ts";
7
+ import { type Services } from "./services.ts";
5
8
  type MaybePromise<T> = T | Promise<T>;
9
+ /** The metadata entry a runtime sets on a consumer's request to say what it speaks: its protocol version. */
10
+ export declare const PROTOCOL_KEY = "ankka.protocol";
11
+ /** Thrown when a consumer answers with several messages, or a record key, to a runtime that would drop them. */
12
+ export declare class ProtocolVersionError extends Error {
13
+ constructor(declared: string | undefined);
14
+ }
15
+ /**
16
+ * Refuses to answer with several messages unless the request says the runtime accepts them. A runtime
17
+ * before protocol 1.3 reads the reply as no effect at all and records the change as handled with
18
+ * nothing published, so the request is failed instead: the change is delivered again, visibly.
19
+ */
20
+ export declare function requireSeveralMessages(metadata: Metadata): void;
21
+ /** The change's sequence number from its metadata: an event's, a key value state's revision; absent when there is none. */
22
+ export declare function sequenceNumberOf(metadata: Metadata): bigint | undefined;
6
23
  export declare abstract class Consumer<M, Out = never> {
7
24
  #private;
8
25
  readonly effects: ConsumerEffects<Out>;
9
26
  /** The message's metadata: `ce-subject` is the source instance's id, `ankka.sequence` its sequence number. */
10
27
  get metadata(): Metadata;
11
28
  get subject(): string;
29
+ /** The change's sequence number: an event's, or a key value state's revision. `0n` for a topic's message. */
30
+ get sequenceNumber(): bigint | undefined;
12
31
  get client(): ComponentClient;
32
+ /** The service's secret store: values kept encrypted in the service's own database, never in a journal or a view. */
33
+ get secrets(): Secrets;
34
+ /** A unit test's store in place of the runtime's: `component.secrets = new InMemorySecrets()`. */
35
+ set secrets(store: Secrets);
36
+ /** Other services, called as this one, through the runtime: `this.services.service("orders")`. */
37
+ get services(): Services;
38
+ /** A unit test's in place of the runtime's: `component.services = new ScriptedServices()`. */
39
+ set services(services: Services);
13
40
  /** @internal */
14
41
  get _kind(): "consumer";
15
42
  abstract onMessage(message: M): MaybePromise<ConsumerEffect<Out>>;
@@ -23,9 +50,20 @@ export interface ConsumerClass<M = unknown, Out = unknown, C extends Consumer<M,
23
50
  readonly componentId: string;
24
51
  readonly source?: ComponentRef;
25
52
  readonly topic?: string;
53
+ /** Where a topic source starts the first time its group reads the topic. */
54
+ readonly startFrom?: StartFrom;
55
+ /** Raised to read the topic again from the start position, under a group of its own. Absent is 1. */
56
+ readonly version?: number;
57
+ /** The contract the topic read is expected to carry, checked at start against the project's declaration. */
58
+ readonly contract?: Contract;
59
+ /** The declared broker the topic read is on; absent is the installation's. */
60
+ readonly broker?: string;
61
+ /** Whether the partitions an instance holds are handled at once, each in order. */
62
+ readonly parallel?: boolean;
26
63
  readonly message: Shape<M>;
27
64
  /** The shape of what `produce` sends onward; required with `producesTo`. */
28
65
  readonly out?: Shape<Out>;
29
- readonly producesTo?: string;
66
+ /** The topic produced to: its name, or a `Publication` with the contract stated for it and its broker. */
67
+ readonly producesTo?: string | Publication;
30
68
  }
31
69
  export {};
package/dist/consumer.js CHANGED
@@ -1,5 +1,33 @@
1
1
  // A consumer: reacts to a source's changes, optionally producing onward to a topic.
2
+ import { secretsFor } from "./client.js";
2
3
  import { ConsumerEffects } from "./effects/stateless.js";
4
+ import { servicesFor } from "./services.js";
5
+ /** The metadata entry a runtime sets on a consumer's request to say what it speaks: its protocol version. */
6
+ export const PROTOCOL_KEY = "ankka.protocol";
7
+ /** Thrown when a consumer answers with several messages, or a record key, to a runtime that would drop them. */
8
+ export class ProtocolVersionError extends Error {
9
+ constructor(declared) {
10
+ super(`this runtime speaks protocol ${declared ?? "1.2 or earlier"}; several messages or a record key need 1.3`);
11
+ this.name = "ProtocolVersionError";
12
+ }
13
+ }
14
+ /**
15
+ * Refuses to answer with several messages unless the request says the runtime accepts them. A runtime
16
+ * before protocol 1.3 reads the reply as no effect at all and records the change as handled with
17
+ * nothing published, so the request is failed instead: the change is delivered again, visibly.
18
+ */
19
+ export function requireSeveralMessages(metadata) {
20
+ const declared = metadata[PROTOCOL_KEY];
21
+ const match = declared === undefined ? null : /^(\d+)\.(\d+)$/.exec(declared.trim());
22
+ const accepted = match !== null && (Number(match[1]) > 1 || (Number(match[1]) === 1 && Number(match[2]) >= 3));
23
+ if (!accepted)
24
+ throw new ProtocolVersionError(declared);
25
+ }
26
+ /** The change's sequence number from its metadata: an event's, a key value state's revision; absent when there is none. */
27
+ export function sequenceNumberOf(metadata) {
28
+ const raw = metadata["ankka.sequence"];
29
+ return raw !== undefined && /^-?\d+$/.test(raw) ? BigInt(raw) : undefined;
30
+ }
3
31
  export class Consumer {
4
32
  effects = new ConsumerEffects();
5
33
  #metadata = {};
@@ -11,11 +39,33 @@ export class Consumer {
11
39
  get subject() {
12
40
  return this.#metadata["ce-subject"] ?? "";
13
41
  }
42
+ /** The change's sequence number: an event's, or a key value state's revision. `0n` for a topic's message. */
43
+ get sequenceNumber() {
44
+ return sequenceNumberOf(this.#metadata);
45
+ }
14
46
  get client() {
15
47
  if (!this.#client)
16
48
  throw new Error("client is only available inside onMessage or onDelete");
17
49
  return this.#client;
18
50
  }
51
+ #secrets;
52
+ /** The service's secret store: values kept encrypted in the service's own database, never in a journal or a view. */
53
+ get secrets() {
54
+ return this.#secrets ?? secretsFor(this.client);
55
+ }
56
+ /** A unit test's store in place of the runtime's: `component.secrets = new InMemorySecrets()`. */
57
+ set secrets(store) {
58
+ this.#secrets = store;
59
+ }
60
+ #services;
61
+ /** Other services, called as this one, through the runtime: `this.services.service("orders")`. */
62
+ get services() {
63
+ return this.#services ?? servicesFor(this.client);
64
+ }
65
+ /** A unit test's in place of the runtime's: `component.services = new ScriptedServices()`. */
66
+ set services(services) {
67
+ this.#services = services;
68
+ }
19
69
  /** @internal */
20
70
  get _kind() {
21
71
  return "consumer";
package/dist/context.d.ts CHANGED
@@ -26,6 +26,10 @@ export interface Principal {
26
26
  readonly email: string | null;
27
27
  readonly emailVerified: boolean;
28
28
  readonly roles: readonly string[];
29
+ /** Every other claim of the verified token, as text. */
30
+ readonly claims: Readonly<Record<string, string>>;
31
+ /** The service's own name for the issuer that verified the token, from `ANKKA_AUTH_ISSUERS`. */
32
+ readonly issuer: string | null;
29
33
  }
30
34
  /**
31
35
  * Which workload sent a request, as the platform established it from the client certificate: the gateway
@@ -0,0 +1,36 @@
1
+ /** A contract's name: a topic's letters and `_`, 2 to 100 characters. */
2
+ export declare const CONTRACT_NAME_RULE: RegExp;
3
+ /**
4
+ * What a topic carries, as a project declares it and a component states it: a name and the
5
+ * fingerprint of the schema document. The platform refuses, at a service's start, a side whose name
6
+ * or fingerprint is not the declared one; the name travels as every message's `ce-type`. Nothing
7
+ * checks a message against the schema.
8
+ *
9
+ * The fingerprint is `sha256:` and the hex SHA-256 of the document under RFC 8785 (the JSON
10
+ * Canonicalization Scheme), so whitespace and key order in a saved file do not change it and a
11
+ * changed field does. Every SDK fingerprints the same way, held to `protocol/fixtures/contracts/`.
12
+ */
13
+ export declare class Contract {
14
+ readonly name: string;
15
+ readonly fingerprint: string;
16
+ constructor(name: string, fingerprint: string);
17
+ /** The contract `name` of the schema document at `path`, fetched from the project's declaration. */
18
+ static fromFile(path: string, name: string): Contract;
19
+ /** The contract `name` of a schema document held in memory. */
20
+ static fromBytes(document: Uint8Array | string, name: string): Contract;
21
+ /** @internal */
22
+ equals(other: Contract): boolean;
23
+ }
24
+ /** `sha256:` and the hex SHA-256 of `document` canonicalised under RFC 8785. Throws when it is not JSON. */
25
+ export declare function fingerprintOf(document: Uint8Array | string): string;
26
+ /**
27
+ * A topic a consumer publishes to, with the contract it states for it and the declared broker it is
28
+ * on. `producesTo` takes a plain topic name as the short form.
29
+ */
30
+ export interface Publication {
31
+ readonly topic: string;
32
+ readonly contract?: Contract;
33
+ readonly broker?: string;
34
+ }
35
+ /** The first protocol in which a process can declare a contract, a broker or parallel reading. */
36
+ export declare const CONTRACT_PROTOCOL: readonly [number, number];
@@ -0,0 +1,58 @@
1
+ // A contract: a name and the fingerprint of the schema document a component was built against.
2
+ import { createHash } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+ import * as canonicalizeModule from "canonicalize";
5
+ // `canonicalize` is CommonJS whose declaration says `export default`: under Node's ESM loader the
6
+ // namespace's `default` is `module.exports`, the function, which is also what the types say.
7
+ const canonicalize = canonicalizeModule.default;
8
+ /** A contract's name: a topic's letters and `_`, 2 to 100 characters. */
9
+ export const CONTRACT_NAME_RULE = /^[a-z0-9][a-z0-9._-]{0,98}[a-z0-9]$/;
10
+ /**
11
+ * What a topic carries, as a project declares it and a component states it: a name and the
12
+ * fingerprint of the schema document. The platform refuses, at a service's start, a side whose name
13
+ * or fingerprint is not the declared one; the name travels as every message's `ce-type`. Nothing
14
+ * checks a message against the schema.
15
+ *
16
+ * The fingerprint is `sha256:` and the hex SHA-256 of the document under RFC 8785 (the JSON
17
+ * Canonicalization Scheme), so whitespace and key order in a saved file do not change it and a
18
+ * changed field does. Every SDK fingerprints the same way, held to `protocol/fixtures/contracts/`.
19
+ */
20
+ export class Contract {
21
+ name;
22
+ fingerprint;
23
+ constructor(name, fingerprint) {
24
+ if (!CONTRACT_NAME_RULE.test(name))
25
+ throw new TypeError(`contract name ${JSON.stringify(name)} is not ${CONTRACT_NAME_RULE.source}`);
26
+ this.name = name;
27
+ this.fingerprint = fingerprint;
28
+ }
29
+ /** The contract `name` of the schema document at `path`, fetched from the project's declaration. */
30
+ static fromFile(path, name) {
31
+ return Contract.fromBytes(readFileSync(path), name);
32
+ }
33
+ /** The contract `name` of a schema document held in memory. */
34
+ static fromBytes(document, name) {
35
+ return new Contract(name, fingerprintOf(document));
36
+ }
37
+ /** @internal */
38
+ equals(other) {
39
+ return this.name === other.name && this.fingerprint === other.fingerprint;
40
+ }
41
+ }
42
+ /** `sha256:` and the hex SHA-256 of `document` canonicalised under RFC 8785. Throws when it is not JSON. */
43
+ export function fingerprintOf(document) {
44
+ const text = typeof document === "string" ? document : new TextDecoder("utf-8", { fatal: true }).decode(document);
45
+ let parsed;
46
+ try {
47
+ parsed = JSON.parse(text);
48
+ }
49
+ catch (e) {
50
+ throw new TypeError(`a schema document must be JSON: ${e.message}`);
51
+ }
52
+ const canonical = canonicalize(parsed);
53
+ if (canonical === undefined)
54
+ throw new TypeError("a schema document must be JSON");
55
+ return "sha256:" + createHash("sha256").update(canonical, "utf8").digest("hex");
56
+ }
57
+ /** The first protocol in which a process can declare a contract, a broker or parallel reading. */
58
+ export const CONTRACT_PROTOCOL = [1, 14];
@@ -0,0 +1,33 @@
1
+ import type { EffectLike } from "./common.ts";
2
+ /** One row written under its key, or the key's row deleted. */
3
+ export type RowChange<Row> = {
4
+ readonly key: string;
5
+ readonly row: Row;
6
+ } | {
7
+ readonly key: string;
8
+ readonly deleted: true;
9
+ };
10
+ /**
11
+ * What a keyed view's handler says to do with one change: rows to write and rows to delete, in order.
12
+ * Empty is "nothing". The platform deletes no row the handler did not name; a row is moved by deleting
13
+ * its old key and writing its new one in the same effect.
14
+ */
15
+ export type KeyedViewEffect<Row> = {
16
+ readonly kind: "rows";
17
+ readonly changes: readonly RowChange<Row>[];
18
+ } & EffectLike<never>;
19
+ export declare class KeyedViewEffects<Row> {
20
+ updateRow(key: string, row: Row): KeyedViewEffect<Row>;
21
+ deleteRow(key: string): KeyedViewEffect<Row>;
22
+ /** Several rows written, each under its key. */
23
+ updateRows(entries: Iterable<readonly [string, Row]>): KeyedViewEffect<Row>;
24
+ deleteRows(keys: Iterable<string>): KeyedViewEffect<Row>;
25
+ ignore(): KeyedViewEffect<Row>;
26
+ /** Several effects as one, in the order given. */
27
+ all(...effects: readonly KeyedViewEffect<Row>[]): KeyedViewEffect<Row>;
28
+ }
29
+ /**
30
+ * The final change for each key, a later change to a key winning, keys in the order they first
31
+ * appear; `undefined` is a deletion. How the test kit applies an effect, as the runtime does.
32
+ */
33
+ export declare function reduceRowChanges<Row>(changes: readonly RowChange<Row>[]): [string, Row | undefined][];