ankka 0.11.0 → 0.13.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 (92) hide show
  1. package/README.md +1 -0
  2. package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +910 -0
  3. package/dist/_proto/ankka/protocol/v1/client_pb.js +239 -23
  4. package/dist/_proto/ankka/protocol/v1/consumer_pb.d.ts +10 -1
  5. package/dist/_proto/ankka/protocol/v1/consumer_pb.js +1 -1
  6. package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +177 -0
  7. package/dist/_proto/ankka/protocol/v1/discovery_pb.js +51 -36
  8. package/dist/_proto/ankka/protocol/v1/endpoint_pb.d.ts +26 -0
  9. package/dist/_proto/ankka/protocol/v1/endpoint_pb.js +15 -10
  10. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.d.ts +86 -0
  11. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.js +11 -1
  12. package/dist/_proto/ankka/protocol/v1/mcp_pb.d.ts +120 -0
  13. package/dist/_proto/ankka/protocol/v1/mcp_pb.js +29 -0
  14. package/dist/_proto/ankka/protocol/v1/payload_pb.d.ts +61 -0
  15. package/dist/_proto/ankka/protocol/v1/payload_pb.js +18 -1
  16. package/dist/_proto/ankka/protocol/v1/view_pb.d.ts +7 -1
  17. package/dist/_proto/ankka/protocol/v1/view_pb.js +1 -1
  18. package/dist/_proto/ankka/protocol/v1/wasm_pb.d.ts +12 -0
  19. package/dist/_proto/ankka/protocol/v1/wasm_pb.js +1 -1
  20. package/dist/agent.d.ts +10 -0
  21. package/dist/agent.js +20 -0
  22. package/dist/autonomous.d.ts +5 -0
  23. package/dist/autonomous.js +10 -0
  24. package/dist/client-keys.d.ts +89 -0
  25. package/dist/client-keys.js +182 -0
  26. package/dist/client.d.ts +96 -3
  27. package/dist/client.js +228 -6
  28. package/dist/consumer.d.ts +23 -1
  29. package/dist/consumer.js +25 -1
  30. package/dist/context.d.ts +25 -1
  31. package/dist/context.js +5 -2
  32. package/dist/contract.d.ts +4 -0
  33. package/dist/contract.js +2 -0
  34. package/dist/effects/common.d.ts +7 -0
  35. package/dist/effects/common.js +9 -1
  36. package/dist/effects/eventSourced.d.ts +26 -1
  37. package/dist/effects/eventSourced.js +58 -5
  38. package/dist/endpoint.d.ts +10 -0
  39. package/dist/endpoint.js +20 -0
  40. package/dist/eventSourcedEntity.d.ts +8 -3
  41. package/dist/graph.d.ts +14 -1
  42. package/dist/graph.js +17 -1
  43. package/dist/index.d.ts +10 -4
  44. package/dist/index.js +8 -2
  45. package/dist/keyedView.d.ts +4 -1
  46. package/dist/keyedView.js +7 -1
  47. package/dist/kinds.js +2 -0
  48. package/dist/mcp-endpoint.d.ts +144 -0
  49. package/dist/mcp-endpoint.js +256 -0
  50. package/dist/routes.d.ts +46 -3
  51. package/dist/routes.js +56 -3
  52. package/dist/server/discovery.d.ts +12 -0
  53. package/dist/server/discovery.js +115 -4
  54. package/dist/server/eventSourced.d.ts +4 -1
  55. package/dist/server/eventSourced.js +24 -6
  56. package/dist/server/http.d.ts +8 -0
  57. package/dist/server/http.js +12 -4
  58. package/dist/server/mcp.d.ts +17 -0
  59. package/dist/server/mcp.js +157 -0
  60. package/dist/server/server.d.ts +11 -1
  61. package/dist/server/server.js +19 -0
  62. package/dist/server/stateless.js +5 -4
  63. package/dist/server/storageEndpoint.d.ts +14 -0
  64. package/dist/server/storageEndpoint.js +82 -0
  65. package/dist/service.d.ts +12 -1
  66. package/dist/service.js +73 -5
  67. package/dist/sigv4.d.ts +47 -0
  68. package/dist/sigv4.js +149 -0
  69. package/dist/spec.d.ts +8 -2
  70. package/dist/spec.js +62 -6
  71. package/dist/standing.d.ts +18 -0
  72. package/dist/standing.js +26 -0
  73. package/dist/startFrom.d.ts +4 -0
  74. package/dist/startFrom.js +8 -1
  75. package/dist/storage.d.ts +288 -0
  76. package/dist/storage.js +1081 -0
  77. package/dist/testkit/index.d.ts +1 -1
  78. package/dist/testkit/integration.d.ts +9 -0
  79. package/dist/testkit/integration.js +5 -1
  80. package/dist/testkit/kinds.d.ts +25 -9
  81. package/dist/testkit/kinds.js +53 -28
  82. package/dist/testkit/unit.d.ts +31 -3
  83. package/dist/testkit/unit.js +55 -11
  84. package/dist/timedAction.d.ts +10 -0
  85. package/dist/timedAction.js +20 -0
  86. package/dist/version.d.ts +1 -1
  87. package/dist/version.js +1 -1
  88. package/dist/view.d.ts +18 -3
  89. package/dist/view.js +13 -4
  90. package/dist/workflow.d.ts +8 -0
  91. package/dist/workflow.js +15 -0
  92. package/package.json +1 -1
@@ -1,3 +1,3 @@
1
- export { EventSourcedTestKit, EndpointTestKit, Response, type RequestOptions, type Materialised, type SocketRun } from "./unit.ts";
1
+ export { EventSourcedTestKit, EndpointTestKit, Response, type RequestOptions, type Materialised, type SocketRun, type ReplicationMode } from "./unit.ts";
2
2
  export { KeyValueTestKit, WorkflowTestKit, ViewTestKit, KeyedViewTestKit, ConsumerTestKit, GraphConsumerTestKit, TimedActionTestKit, AgentTestKit, ScriptedModel, type WorkflowProgress, type ModelResponse, type ModelCall, type GraphChange, } from "./kinds.ts";
3
3
  export { AnkkaTestKit, PUBLISHED_SIDECAR, sidecarImage, type AnkkaTestKitOptions, type HttpResponse, type Http, type Beside } from "./integration.ts";
@@ -1,5 +1,6 @@
1
1
  import type { ComponentClient } from "../client.ts";
2
2
  import type { ServiceBuilder } from "../service.ts";
3
+ import { type Buckets } from "../storage.ts";
3
4
  export interface AnkkaTestKitOptions {
4
5
  /** The sidecar image; `sidecarImage()` by default. */
5
6
  readonly image?: string;
@@ -9,6 +10,12 @@ export interface AnkkaTestKitOptions {
9
10
  /** How long to wait for the sidecar to report healthy; 120s by default. */
10
11
  readonly readyTimeoutMs?: number;
11
12
  readonly log?: (message: string) => void;
13
+ /**
14
+ * The service's buckets. By default the store `ANKKA_STORAGE_BACKEND=memory` gives this process: in
15
+ * memory, with the buckets `service.json` in the working directory declares (the unnamed one, and no
16
+ * named one, when there is no such file). `kit.storage` is the same store.
17
+ */
18
+ readonly storage?: Buckets;
12
19
  }
13
20
  /** What `http` answers with. */
14
21
  export declare class HttpResponse {
@@ -47,6 +54,8 @@ export declare class AnkkaTestKit {
47
54
  #private;
48
55
  /** The component client every handler in `service` uses, pointed at the sidecar's mapped callback port. */
49
56
  readonly client: ComponentClient;
57
+ /** The buckets the service holds: what a handler kept, a test reads here, and what a test puts here a handler reads. */
58
+ readonly storage: Buckets;
50
59
  private constructor();
51
60
  /** Starts Postgres, this process's server and the sidecar; a start that fails half-way stops what it started. */
52
61
  static start(service: ServiceBuilder, options?: AnkkaTestKitOptions): Promise<AnkkaTestKit>;
@@ -16,6 +16,7 @@ import { randomBytes } from "node:crypto";
16
16
  import { chmodSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
17
17
  import { tmpdir } from "node:os";
18
18
  import { join } from "node:path";
19
+ import { chooseStorage } from "../storage.js";
19
20
  import { VERSION } from "../version.js";
20
21
  const POSTGRES_IMAGE = "postgres:17-alpine";
21
22
  const HTTP_PORT = 9000;
@@ -149,7 +150,10 @@ export class AnkkaTestKit {
149
150
  #http;
150
151
  /** The component client every handler in `service` uses, pointed at the sidecar's mapped callback port. */
151
152
  client;
153
+ /** The buckets the service holds: what a handler kept, a test reads here, and what a test puts here a handler reads. */
154
+ storage;
152
155
  constructor(service, options) {
156
+ this.storage = options.storage ?? chooseStorage({ ...process.env, ANKKA_STORAGE_BACKEND: "memory" }).buckets;
153
157
  this.#service = service;
154
158
  this.#image = options.image ?? sidecarImage();
155
159
  this.#env = options.env ?? {};
@@ -203,7 +207,7 @@ export class AnkkaTestKit {
203
207
  .withCopyDirectoriesToContainer([{ source: this.#ddlDir, target: "/docker-entrypoint-initdb.d", mode: 0o755 }])
204
208
  .start();
205
209
  // This process's server, on all interfaces: the sidecar is in a container and dials in.
206
- this.#server = this.#service.server({ host: "0.0.0.0", port: 0, log: this.#log });
210
+ this.#server = this.#service.server({ host: "0.0.0.0", port: 0, log: this.#log, storage: this.storage });
207
211
  this.#processPort = (await this.#server.start()).port;
208
212
  await this.#startSidecar();
209
213
  }
@@ -1,4 +1,5 @@
1
1
  import { ComponentClient } from "../client.ts";
2
+ import { type Buckets } from "../storage.ts";
2
3
  import { type ErrorDetail, type Metadata } from "../effects/common.ts";
3
4
  import type { StepRef } from "../effects/workflow.ts";
4
5
  import type { ConsumerEffect, TimedActionEffect, ViewEffect } from "../effects/stateless.ts";
@@ -15,6 +16,7 @@ import type { Agent, AgentClass } from "../agent.ts";
15
16
  import type { HandlerRef } from "../handlers.ts";
16
17
  import { type MaterialisedKeyValue, type MaterialisedStep, type MaterialisedWorkflowCommand } from "../materialise.ts";
17
18
  import { type AgentOutcome, type DecisionInput } from "../approvals.ts";
19
+ import type { Standing } from "../standing.ts";
18
20
  /** Drives one key value entity instance through its handlers. */
19
21
  export declare class KeyValueTestKit<S, C extends KeyValueEntity<S>> {
20
22
  #private;
@@ -41,8 +43,10 @@ export declare class WorkflowTestKit<S, C extends Workflow<S>> {
41
43
  #private;
42
44
  readonly entityId: string;
43
45
  state: S;
46
+ /** The buckets the workflow's steps are given; the test reads what they kept here. */
47
+ readonly storage: Buckets;
44
48
  private constructor();
45
- static of<S, C extends Workflow<S>>(cls: WorkflowClass<S, C>, entityId?: string, client?: ComponentClient): WorkflowTestKit<S, C>;
49
+ static of<S, C extends Workflow<S>>(cls: WorkflowClass<S, C>, entityId?: string, client?: ComponentClient, storage?: Buckets): WorkflowTestKit<S, C>;
46
50
  get progress(): WorkflowProgress;
47
51
  call<I, R>(handler: HandlerRef<C, I, R, any> | string, input?: I, metadata?: Metadata): Promise<MaterialisedWorkflowCommand<S, R>>;
48
52
  /** Runs one step, by name or declaration, with an input (or the pending transition's). */
@@ -58,8 +62,11 @@ export declare class ViewTestKit<E, Row, C extends View<E, Row>> {
58
62
  readonly rows: Map<string, Row>;
59
63
  private constructor();
60
64
  static of<E, Row, C extends View<E, Row>>(cls: ViewClass<E, Row, C>, client?: ComponentClient): ViewTestKit<E, Row, C>;
61
- /** A change from source instance `key`: the event, round-tripped through the source's event codec. */
62
- onChange(key: string, event: E, metadata?: Metadata): Promise<ViewEffect<Row>>;
65
+ /**
66
+ * A change from source instance `key`: the event, round-tripped through the source's event codec.
67
+ * `standing` is a workflow's, for a view whose source is one.
68
+ */
69
+ onChange(key: string, event: E, metadata?: Metadata, standing?: Standing): Promise<ViewEffect<Row>>;
63
70
  /** The source instance `key` was deleted. */
64
71
  onDelete(key: string, metadata?: Metadata): Promise<ViewEffect<Row>>;
65
72
  get(key: string): Row | null;
@@ -80,8 +87,8 @@ export declare class KeyedViewTestKit<Row, C extends KeyedView<Row>> {
80
87
  static of<Row, C extends KeyedView<Row>>(cls: KeyedViewClass<Row, C>, client?: ComponentClient): KeyedViewTestKit<Row, C>;
81
88
  /** What the declared query `name` answers, given the values it is asked with. */
82
89
  answering(name: string, answer: (values: Readonly<Record<string, string>>) => Row[]): this;
83
- /** A change of the entity `key` of `entity`, round-tripped through that source's codec. */
84
- change<E>(entity: ComponentRef, key: string, event: E): Promise<KeyedViewEffect<Row>>;
90
+ /** A change of `key` of `entity`, round-tripped through that source's codec; `standing` a workflow's. */
91
+ change<E>(entity: ComponentRef, key: string, event: E, standing?: Standing): Promise<KeyedViewEffect<Row>>;
85
92
  /** The entity `key` of `entity` was deleted. */
86
93
  deleted(entity: ComponentRef, key: string): Promise<KeyedViewEffect<Row>>;
87
94
  get(key: string): Row | null;
@@ -98,13 +105,15 @@ export declare class ConsumerTestKit<M, Out, C extends Consumer<M, Out>> {
98
105
  readonly metadata: Metadata;
99
106
  readonly key?: string;
100
107
  }[];
108
+ /** The buckets the consumer is given; the test reads what it kept here. */
109
+ readonly storage: Buckets;
101
110
  private constructor();
102
- static of<M, Out, C extends Consumer<M, Out>>(cls: ConsumerClass<M, Out, C>, client?: ComponentClient): ConsumerTestKit<M, Out, C>;
111
+ static of<M, Out, C extends Consumer<M, Out>>(cls: ConsumerClass<M, Out, C>, client?: ComponentClient, storage?: Buckets): ConsumerTestKit<M, Out, C>;
103
112
  /**
104
113
  * A message from source instance `subject`. The metadata says the runtime speaks this SDK's protocol
105
114
  * version; pass `"ankka.protocol"` or `"ankka.sequence"` to say otherwise.
106
115
  */
107
- onMessage(message: M, subject?: string, metadata?: Metadata): Promise<ConsumerEffect<Out>>;
116
+ onMessage(message: M, subject?: string, metadata?: Metadata, standing?: Standing): Promise<ConsumerEffect<Out>>;
108
117
  onDelete(subject?: string, metadata?: Metadata): Promise<ConsumerEffect<Out>>;
109
118
  }
110
119
  /**
@@ -114,8 +123,10 @@ export declare class ConsumerTestKit<M, Out, C extends Consumer<M, Out>> {
114
123
  */
115
124
  export declare class GraphConsumerTestKit<M> {
116
125
  #private;
126
+ /** The buckets the graph consumer is given; the test reads what it kept here. */
127
+ readonly storage: Buckets;
117
128
  private constructor();
118
- static of<M>(cls: GraphConsumerClass<M>, client?: ComponentClient): GraphConsumerTestKit<M>;
129
+ static of<M>(cls: GraphConsumerClass<M>, client?: ComponentClient, storage?: Buckets): GraphConsumerTestKit<M>;
119
130
  /** A change from source instance `subject` (default `"test"`) at `sequence` (default 1). */
120
131
  onMessage(message: M, options?: GraphChange): Promise<Delta[]>;
121
132
  /** The source instance was deleted, at `sequence`. */
@@ -131,8 +142,10 @@ export interface GraphChange {
131
142
  /** Invokes a timed action's handlers as the sweeper would. */
132
143
  export declare class TimedActionTestKit<C extends TimedAction> {
133
144
  #private;
145
+ /** The buckets the timed action is given; the test reads what it kept here. */
146
+ readonly storage: Buckets;
134
147
  private constructor();
135
- static of<C extends TimedAction>(cls: TimedActionClass<C>, client?: ComponentClient): TimedActionTestKit<C>;
148
+ static of<C extends TimedAction>(cls: TimedActionClass<C>, client?: ComponentClient, storage?: Buckets): TimedActionTestKit<C>;
136
149
  invoke<I>(action: HandlerRef<C, I, any, any> | string, input?: I, metadata?: Metadata): Promise<TimedActionEffect>;
137
150
  }
138
151
  /** What a scripted model answers next. */
@@ -185,9 +198,12 @@ export declare class AgentTestKit<C extends Agent> {
185
198
  role: "user" | "assistant";
186
199
  text: string;
187
200
  }[];
201
+ /** The buckets the agent's tools are given; the test reads what they kept here. */
202
+ readonly storage: Buckets;
188
203
  private constructor();
189
204
  static of<C extends Agent>(cls: AgentClass<C>, sessionId: string, model: ScriptedModel, client?: ComponentClient, options?: {
190
205
  readonly mcp?: ScriptedMcp;
206
+ readonly storage?: Buckets;
191
207
  }): AgentTestKit<C>;
192
208
  /** One turn: returns the reply text (JSON text for a `thenReplyJson` plan). A refusal or a blocked guardrail rejects with `CommandError`; a turn that waits, with `ApprovalAwaited`. */
193
209
  ask<I>(handler: HandlerRef<C, I, any, any> | string, input?: I): Promise<string>;
@@ -2,6 +2,7 @@
2
2
  // agents. Same rules as the event sourced kit: no sidecar, effects as values, every value round-tripped
3
3
  // through the component's codecs, the server's reduction.
4
4
  import { ComponentClient, noClient } from "../client.js";
5
+ import { InMemoryBuckets } from "../storage.js";
5
6
  import { binaryCodecs, codecFor } from "../codec.js";
6
7
  import { commandContext } from "../context.js";
7
8
  import { CommandError, ErrorCode } from "../effects/common.js";
@@ -78,17 +79,20 @@ export class WorkflowTestKit {
78
79
  entityId;
79
80
  state;
80
81
  #progress = { pending: null, paused: null, ended: false, failed: null };
81
- constructor(cls, entityId, client) {
82
+ /** The buckets the workflow's steps are given; the test reads what they kept here. */
83
+ storage;
84
+ constructor(cls, entityId, client, storage) {
82
85
  this.#registered = registryFor(cls, client).component(cls.componentId);
83
86
  this.#cls = cls;
84
87
  this.#client = client;
88
+ this.storage = storage;
85
89
  this.entityId = entityId;
86
90
  const instance = new cls();
87
91
  instance._bindInstance(entityId);
88
92
  this.state = instance.emptyState();
89
93
  }
90
- static of(cls, entityId = "test", client = noClient()) {
91
- return new WorkflowTestKit(cls, entityId, client);
94
+ static of(cls, entityId = "test", client = noClient(), storage = new InMemoryBuckets()) {
95
+ return new WorkflowTestKit(cls, entityId, client, storage);
92
96
  }
93
97
  get progress() {
94
98
  return this.#progress;
@@ -96,6 +100,7 @@ export class WorkflowTestKit {
96
100
  #instance() {
97
101
  const w = new this.#cls();
98
102
  w._bindInstance(this.entityId);
103
+ w.storage = this.storage;
99
104
  return w;
100
105
  }
101
106
  async call(handler, input, metadata = {}) {
@@ -135,6 +140,7 @@ export class WorkflowTestKit {
135
140
  const wireInput = ref.input ? roundTrip(codecFor(ref.input), input) : undefined;
136
141
  const w = this.#instance();
137
142
  w._bindCommand(this.state, commandContext(this.#registered.id, this.entityId, 0n, {}), this.#client);
143
+ w._enterStep();
138
144
  let effect;
139
145
  try {
140
146
  effect = (await ref.run(w, wireInput));
@@ -199,11 +205,11 @@ export class ViewTestKit {
199
205
  static of(cls, client = noClient()) {
200
206
  return new ViewTestKit(cls, client);
201
207
  }
202
- async #apply(key, fn, metadata) {
208
+ async #apply(key, fn, metadata, standing) {
203
209
  const view = new this.#cls();
204
210
  const rowCodec = this.#registered.rowCodec;
205
211
  const current = this.rows.get(key);
206
- view._bind(current === undefined ? null : roundTrip(rowCodec, current), { "ce-subject": key, ...metadata }, this.#client.withMetadata(metadata));
212
+ view._bind(current === undefined ? null : roundTrip(rowCodec, current), { "ce-subject": key, ...metadata }, this.#client.withMetadata(metadata), standing);
207
213
  const effect = await fn(view);
208
214
  switch (effect.kind) {
209
215
  case "update-row":
@@ -217,10 +223,13 @@ export class ViewTestKit {
217
223
  }
218
224
  return effect;
219
225
  }
220
- /** A change from source instance `key`: the event, round-tripped through the source's event codec. */
221
- onChange(key, event, metadata = {}) {
226
+ /**
227
+ * A change from source instance `key`: the event, round-tripped through the source's event codec.
228
+ * `standing` is a workflow's, for a view whose source is one.
229
+ */
230
+ onChange(key, event, metadata = {}, standing) {
222
231
  const wire = roundTrip(this.#registered.eventCodec, event);
223
- return this.#apply(key, (v) => v.onChange(wire), metadata);
232
+ return this.#apply(key, (v) => v.onChange(wire), metadata, standing);
224
233
  }
225
234
  /** The source instance `key` was deleted. */
226
235
  onDelete(key, metadata = {}) {
@@ -265,7 +274,7 @@ export class KeyedViewTestKit {
265
274
  throw new Error(`${this.#registered.id} reads no ${JSON.stringify(entity.componentId)}`);
266
275
  return source;
267
276
  }
268
- async #apply(key, run) {
277
+ async #apply(key, run, standing) {
269
278
  const view = new this.#cls();
270
279
  const rowCodec = this.#registered.rowCodec;
271
280
  const id = this.#registered.id;
@@ -283,7 +292,7 @@ export class KeyedViewTestKit {
283
292
  };
284
293
  this.#sequence += 1;
285
294
  const metadata = { "ce-subject": key, "ankka.sequence": String(this.#sequence) };
286
- view._bind(metadata, this.#client.withMetadata(metadata), rows);
295
+ view._bind(metadata, this.#client.withMetadata(metadata), rows, standing);
287
296
  const effect = ((await run(view)) ?? { kind: "rows", changes: [] });
288
297
  if (effect.kind !== "rows")
289
298
  throw new TypeError(`${id}'s handler returned something that is not a keyed view effect`);
@@ -295,11 +304,11 @@ export class KeyedViewTestKit {
295
304
  }
296
305
  return effect;
297
306
  }
298
- /** A change of the entity `key` of `entity`, round-tripped through that source's codec. */
299
- change(entity, key, event) {
307
+ /** A change of `key` of `entity`, round-tripped through that source's codec; `standing` a workflow's. */
308
+ change(entity, key, event, standing) {
300
309
  const source = this.#source(entity);
301
310
  const wire = roundTrip(source.eventCodec, event);
302
- return this.#apply(key, (v) => source.onChange(v, wire));
311
+ return this.#apply(key, (v) => source.onChange(v, wire), standing);
303
312
  }
304
313
  /** The entity `key` of `entity` was deleted. */
305
314
  deleted(entity, key) {
@@ -320,17 +329,21 @@ export class ConsumerTestKit {
320
329
  * `key` is the record key a message of `produceAll` named; absent, the message is keyed by its subject.
321
330
  */
322
331
  produced = [];
323
- constructor(cls, client) {
332
+ /** The buckets the consumer is given; the test reads what it kept here. */
333
+ storage;
334
+ constructor(cls, client, storage) {
324
335
  this.#registered = registryFor(cls, client).component(cls.componentId);
325
336
  this.#cls = cls;
326
337
  this.#client = client;
338
+ this.storage = storage;
327
339
  }
328
- static of(cls, client = noClient()) {
329
- return new ConsumerTestKit(cls, client);
340
+ static of(cls, client = noClient(), storage = new InMemoryBuckets()) {
341
+ return new ConsumerTestKit(cls, client, storage);
330
342
  }
331
- async #apply(fn, metadata) {
343
+ async #apply(fn, metadata, standing) {
332
344
  const consumer = new this.#cls();
333
- consumer._bind(metadata, this.#client.withMetadata(metadata));
345
+ consumer._bind(metadata, this.#client.withMetadata(metadata), standing);
346
+ consumer.storage = this.storage;
334
347
  const effect = await fn(consumer);
335
348
  const outCodec = () => {
336
349
  const codec = this.#registered.outCodec;
@@ -354,9 +367,9 @@ export class ConsumerTestKit {
354
367
  * A message from source instance `subject`. The metadata says the runtime speaks this SDK's protocol
355
368
  * version; pass `"ankka.protocol"` or `"ankka.sequence"` to say otherwise.
356
369
  */
357
- onMessage(message, subject = "test", metadata = {}) {
370
+ onMessage(message, subject = "test", metadata = {}, standing) {
358
371
  const wire = roundTrip(this.#registered.messageCodec, message);
359
- return this.#apply((c) => c.onMessage(wire), { "ce-subject": subject, [PROTOCOL_KEY]: PROTOCOL_VERSION, ...metadata });
372
+ return this.#apply((c) => c.onMessage(wire), { "ce-subject": subject, [PROTOCOL_KEY]: PROTOCOL_VERSION, ...metadata }, standing);
360
373
  }
361
374
  onDelete(subject = "test", metadata = {}) {
362
375
  return this.#apply((c) => c.onDelete(), { "ce-subject": subject, [PROTOCOL_KEY]: PROTOCOL_VERSION, ...metadata });
@@ -371,13 +384,16 @@ export class GraphConsumerTestKit {
371
384
  #registered;
372
385
  #cls;
373
386
  #client;
374
- constructor(cls, client) {
387
+ /** The buckets the graph consumer is given; the test reads what it kept here. */
388
+ storage;
389
+ constructor(cls, client, storage) {
375
390
  this.#registered = registryFor(cls, client).component(cls.componentId);
376
391
  this.#cls = cls;
377
392
  this.#client = client;
393
+ this.storage = storage;
378
394
  }
379
- static of(cls, client = noClient()) {
380
- return new GraphConsumerTestKit(cls, client);
395
+ static of(cls, client = noClient(), storage = new InMemoryBuckets()) {
396
+ return new GraphConsumerTestKit(cls, client, storage);
381
397
  }
382
398
  async #apply(fn, options) {
383
399
  const metadata = {
@@ -388,6 +404,7 @@ export class GraphConsumerTestKit {
388
404
  };
389
405
  const consumer = new this.#cls();
390
406
  consumer._bind(metadata, this.#client.withMetadata(metadata));
407
+ consumer.storage = this.storage;
391
408
  const records = deltaRecords(await fn(consumer), metadata);
392
409
  if (records.length > 0)
393
410
  requireSeveralMessages(metadata);
@@ -408,19 +425,23 @@ export class TimedActionTestKit {
408
425
  #registered;
409
426
  #cls;
410
427
  #client;
411
- constructor(cls, client) {
428
+ /** The buckets the timed action is given; the test reads what it kept here. */
429
+ storage;
430
+ constructor(cls, client, storage) {
412
431
  this.#registered = registryFor(cls, client).component(cls.componentId);
413
432
  this.#cls = cls;
414
433
  this.#client = client;
434
+ this.storage = storage;
415
435
  }
416
- static of(cls, client = noClient()) {
417
- return new TimedActionTestKit(cls, client);
436
+ static of(cls, client = noClient(), storage = new InMemoryBuckets()) {
437
+ return new TimedActionTestKit(cls, client, storage);
418
438
  }
419
439
  async invoke(action, input, metadata = {}) {
420
440
  const ref = findHandler(this.#registered.actions, action, this.#registered.cls.name);
421
441
  const wireInput = ref.input ? roundTrip(codecFor(ref.input), input) : undefined;
422
442
  const instance = new this.#cls();
423
443
  instance._bind({ "ankka.timer": "test", "ankka.attempts": "1", ...metadata }, this.#client.withMetadata(metadata));
444
+ instance.storage = this.storage;
424
445
  return (await ref.run(instance, wireInput));
425
446
  }
426
447
  }
@@ -472,20 +493,24 @@ export class AgentTestKit {
472
493
  #turn;
473
494
  #decided = new Set();
474
495
  #nextId = 0;
475
- constructor(cls, sessionId, model, client, mcp) {
496
+ /** The buckets the agent's tools are given; the test reads what they kept here. */
497
+ storage;
498
+ constructor(cls, sessionId, model, client, mcp, storage) {
476
499
  this.#registered = registryFor(cls, client).component(cls.componentId);
477
500
  this.#cls = cls;
478
501
  this.#client = client;
502
+ this.storage = storage;
479
503
  this.#model = model;
480
504
  this.#mcp = mcp;
481
505
  this.sessionId = sessionId;
482
506
  }
483
507
  static of(cls, sessionId, model, client = noClient(), options = {}) {
484
- return new AgentTestKit(cls, sessionId, model, client, options.mcp ?? {});
508
+ return new AgentTestKit(cls, sessionId, model, client, options.mcp ?? {}, options.storage ?? new InMemoryBuckets());
485
509
  }
486
510
  #agent() {
487
511
  const a = new this.#cls();
488
512
  a._bind(this.sessionId, {}, this.#client);
513
+ a.storage = this.storage;
489
514
  return a;
490
515
  }
491
516
  /** One turn: returns the reply text (JSON text for a `thenReplyJson` plan). A refusal or a blocked guardrail rejects with `CommandError`; a turn that waits, with `ApprovalAwaited`. */
@@ -1,15 +1,30 @@
1
1
  import { ComponentClient } from "../client.ts";
2
+ import { type Buckets } from "../storage.ts";
2
3
  import { type Principal } from "../context.ts";
3
- import type { ErrorDetail, Metadata, Retention } from "../effects/common.ts";
4
+ import { type ErrorDetail, type Metadata, type Retention } from "../effects/common.ts";
5
+ import type { ReplicationChange } from "../effects/eventSourced.ts";
4
6
  import type { Endpoint, EndpointClass } from "../endpoint.ts";
5
7
  import { EventSourcedEntity, type EventSourcedEntityClass } from "../eventSourcedEntity.ts";
6
8
  import type { HandlerRef } from "../handlers.ts";
7
9
  import { type Materialised } from "../materialise.ts";
8
10
  export type { Materialised };
9
11
  /** Drives one event sourced entity instance through its handlers, folding events as it goes. */
12
+ /** How a replicated entity's copy stands in the test kit (1.19); `installation` is the copy's own. */
13
+ export type ReplicationMode = {
14
+ readonly kind: "unreplicated";
15
+ } | {
16
+ readonly kind: "home";
17
+ readonly installation?: string;
18
+ } | {
19
+ readonly kind: "follower";
20
+ readonly home: string;
21
+ readonly installation?: string;
22
+ };
10
23
  export declare class EventSourcedTestKit<S, E, C extends EventSourcedEntity<S, E>> {
11
24
  #private;
12
25
  readonly entityId: string;
26
+ /** The handover or change to the replication set the last call asked for, if any. */
27
+ lastReplication: ReplicationChange | undefined;
13
28
  /** The state now, after every call so far. */
14
29
  state: S;
15
30
  /** The sequence number of the last event, `0n` before any. */
@@ -18,7 +33,18 @@ export declare class EventSourcedTestKit<S, E, C extends EventSourcedEntity<S, E
18
33
  readonly allEvents: E[];
19
34
  private constructor();
20
35
  /** A kit for `cls` as instance `entityId`. Calls to other components reach `client`, which by default refuses them. */
21
- static of<S, E, C extends EventSourcedEntity<S, E>>(cls: EventSourcedEntityClass<S, E, C>, entityId?: string, client?: ComponentClient): EventSourcedTestKit<S, E, C>;
36
+ static of<S, E, C extends EventSourcedEntity<S, E>>(cls: EventSourcedEntityClass<S, E, C>, entityId?: string, client?: ComponentClient,
37
+ /**
38
+ * How a replicated entity's copy stands (1.19): `{ kind: "home" }`, where every command runs, or
39
+ * `{ kind: "follower", home }`, where a command that may persist is refused `NOT_HOME` and a query is
40
+ * answered from what `replay` gave it. Not replicated by default.
41
+ */
42
+ mode?: ReplicationMode): EventSourcedTestKit<S, E, C>;
43
+ /**
44
+ * Applies `events` as history the copy already holds, written at `writtenAt` (this installation by
45
+ * default): for a follower, what it has mirrored from its home.
46
+ */
47
+ replay(events: readonly E[], writtenAt?: string | null): void;
22
48
  /** Sends a command or query and returns what its effect amounts to. */
23
49
  call<I, R>(handler: HandlerRef<C, I, R, any> | string, input?: I, metadata?: Metadata): Promise<Materialised<S, E, R>>;
24
50
  }
@@ -47,8 +73,10 @@ export interface SocketRun {
47
73
  /** Calls an endpoint's routes by path, matching them as the sidecar's router does: literal segments outrank parameters. */
48
74
  export declare class EndpointTestKit<C extends Endpoint> {
49
75
  #private;
76
+ /** The buckets the endpoint is given; the test reads what its routes kept here. */
77
+ readonly storage: Buckets;
50
78
  private constructor();
51
- static of<C extends Endpoint>(cls: EndpointClass<C>, client?: ComponentClient): EndpointTestKit<C>;
79
+ static of<C extends Endpoint>(cls: EndpointClass<C>, client?: ComponentClient, storage?: Buckets): EndpointTestKit<C>;
52
80
  request(method: string, path: string, body?: unknown, options?: RequestOptions): Promise<Response>;
53
81
  /**
54
82
  * Opens a socket route with no runtime: the handler is given `frames` one at a time and then told the
@@ -6,8 +6,10 @@
6
6
  import { create } from "@bufbuild/protobuf";
7
7
  import { HttpRequestSchema, SocketInSchema } from "../_proto/ankka/protocol/v1/endpoint_pb.js";
8
8
  import { ComponentClient, noClient } from "../client.js";
9
+ import { InMemoryBuckets } from "../storage.js";
9
10
  import { binaryCodecs, codecFor } from "../codec.js";
10
- import { commandContext } from "../context.js";
11
+ import { commandContext, eventContext } from "../context.js";
12
+ import { ErrorCode } from "../effects/common.js";
11
13
  import { EventSourcedEntity } from "../eventSourcedEntity.js";
12
14
  import { materialiseEventSourced } from "../materialise.js";
13
15
  import { Ankka } from "../service.js";
@@ -22,19 +24,24 @@ function findHandler(handlers, handler, who) {
22
24
  throw new Error(`${who} has no handler ${JSON.stringify(name)}; declared: ${[...handlers.keys()].sort().join(", ")}`);
23
25
  return found;
24
26
  }
25
- /** Drives one event sourced entity instance through its handlers, folding events as it goes. */
26
27
  export class EventSourcedTestKit {
27
28
  #registered;
28
29
  #entity;
29
30
  #client;
31
+ #mode;
32
+ #here;
30
33
  entityId;
34
+ /** The handover or change to the replication set the last call asked for, if any. */
35
+ lastReplication = undefined;
31
36
  /** The state now, after every call so far. */
32
37
  state;
33
38
  /** The sequence number of the last event, `0n` before any. */
34
39
  sequence = 0n;
35
40
  /** Every event persisted so far, in order. */
36
41
  allEvents = [];
37
- constructor(cls, entityId, client) {
42
+ constructor(cls, entityId, client, mode = { kind: "unreplicated" }) {
43
+ this.#mode = mode;
44
+ this.#here = mode.kind === "unreplicated" ? null : (mode.installation ?? "test");
38
45
  const registry = Ankka.service({ client, log: () => { } })
39
46
  .register(cls)
40
47
  .validate();
@@ -46,15 +53,36 @@ export class EventSourcedTestKit {
46
53
  this.state = this.#entity.emptyState();
47
54
  }
48
55
  /** A kit for `cls` as instance `entityId`. Calls to other components reach `client`, which by default refuses them. */
49
- static of(cls, entityId = "test", client = noClient()) {
50
- return new EventSourcedTestKit(cls, entityId, client);
56
+ static of(cls, entityId = "test", client = noClient(),
57
+ /**
58
+ * How a replicated entity's copy stands (1.19): `{ kind: "home" }`, where every command runs, or
59
+ * `{ kind: "follower", home }`, where a command that may persist is refused `NOT_HOME` and a query is
60
+ * answered from what `replay` gave it. Not replicated by default.
61
+ */
62
+ mode = { kind: "unreplicated" }) {
63
+ return new EventSourcedTestKit(cls, entityId, client, mode);
64
+ }
65
+ /**
66
+ * Applies `events` as history the copy already holds, written at `writtenAt` (this installation by
67
+ * default): for a follower, what it has mirrored from its home.
68
+ */
69
+ replay(events, writtenAt = this.#here) {
70
+ for (const event of events) {
71
+ this.sequence += 1n;
72
+ this.state = this.#entity.applyEvent(this.state, event, eventContext(writtenAt, writtenAt === this.#here, this.sequence));
73
+ }
51
74
  }
52
75
  /** Sends a command or query and returns what its effect amounts to. */
53
76
  async call(handler, input, metadata = {}) {
54
77
  const ref = findHandler(this.#registered.handlers, handler, this.#registered.cls.name);
78
+ if (this.#mode.kind === "follower" && !ref.readOnly) {
79
+ // The runtime forwards such a command to the home; here there is no home to forward to.
80
+ const error = { message: `the home of '${this.entityId}' is '${this.#mode.home}'; a follower handles no command that may persist`, code: ErrorCode.NotHome };
81
+ return Object.freeze({ events: [], newState: this.state, retention: null, reply: undefined, error, noReply: false });
82
+ }
55
83
  const inputCodec = ref.input ? codecFor(ref.input) : undefined;
56
84
  const wireInput = inputCodec ? roundTrip(inputCodec, input) : undefined;
57
- const context = commandContext(this.#registered.id, this.entityId, this.sequence, metadata);
85
+ const context = commandContext(this.#registered.id, this.entityId, this.sequence, metadata, undefined, this.#here, this.#mode.kind !== "follower");
58
86
  this.#entity._bindCommand(this.state, context, this.#client.withMetadata(metadata));
59
87
  let effect;
60
88
  try {
@@ -65,7 +93,20 @@ export class EventSourcedTestKit {
65
93
  }
66
94
  if (ref.readOnly && effect.kind !== "read-only")
67
95
  throw new Error(`${this.#registered.id}/${ref.name} is a query and returned a persisting effect`);
68
- const m = materialiseEventSourced(effect, this.state, (s, e) => this.#entity.applyEvent(s, e));
96
+ let applied = this.sequence;
97
+ const m = materialiseEventSourced(effect, this.state, (s, e) => {
98
+ applied += 1n;
99
+ return this.#entity.applyEvent(s, e, eventContext(this.#here, true, applied));
100
+ });
101
+ const replication = effect.kind === "persist" ? effect.replication : undefined;
102
+ if (!m.error && replication && this.#mode.kind === "unreplicated") {
103
+ const error = {
104
+ message: `${this.#registered.id} is not replicated in this test kit; drive it with { kind: "home" } to see the handover or the change to the replication set it asks for`,
105
+ code: ErrorCode.BadRequest,
106
+ };
107
+ return Object.freeze({ events: [], newState: this.state, retention: null, reply: undefined, error, noReply: false });
108
+ }
109
+ this.lastReplication = m.error ? undefined : replication;
69
110
  const eventCodec = this.#registered.eventCodec;
70
111
  const stateCodec = this.#registered.stateCodec;
71
112
  const events = m.events.map((e) => roundTrip(eventCodec, e));
@@ -103,15 +144,18 @@ const PLACEHOLDER = /\{[A-Za-z_][A-Za-z0-9_]*\}/g;
103
144
  export class EndpointTestKit {
104
145
  #cls;
105
146
  #dispatcher;
106
- constructor(cls, client) {
147
+ /** The buckets the endpoint is given; the test reads what its routes kept here. */
148
+ storage;
149
+ constructor(cls, client, storage) {
107
150
  this.#cls = cls;
151
+ this.storage = storage;
108
152
  const registry = Ankka.service({ client, log: () => { } })
109
153
  .register(cls)
110
154
  .validate();
111
- this.#dispatcher = createHttpDispatcher({ registry, client, log: () => { } });
155
+ this.#dispatcher = createHttpDispatcher({ registry, client, log: () => { }, storage });
112
156
  }
113
- static of(cls, client = noClient()) {
114
- return new EndpointTestKit(cls, client);
157
+ static of(cls, client = noClient(), storage = new InMemoryBuckets()) {
158
+ return new EndpointTestKit(cls, client, storage);
115
159
  }
116
160
  #match(method, path) {
117
161
  const prefix = this.#cls.prefix;
@@ -1,5 +1,7 @@
1
1
  import type { Metadata } from "./effects/common.ts";
2
+ import { type ClientKeys } from "./client-keys.ts";
2
3
  import { type ComponentClient, type Secrets } from "./client.ts";
4
+ import { type Buckets } from "./storage.ts";
3
5
  import type { HandlerTable } from "./handlers.ts";
4
6
  import { TimedActionEffects } from "./effects/stateless.ts";
5
7
  import { type Services } from "./services.ts";
@@ -19,6 +21,14 @@ export declare abstract class TimedAction {
19
21
  get secrets(): Secrets;
20
22
  /** A unit test's store in place of the runtime's: `component.secrets = new InMemorySecrets()`. */
21
23
  set secrets(store: Secrets);
24
+ /** The service's client keys (1.20): issued for its MCP endpoint, kept by the runtime, each shown once. */
25
+ get clientKeys(): ClientKeys;
26
+ /** A unit test's in place of the runtime's: `component.clientKeys = new InMemoryClientKeys()`. */
27
+ set clientKeys(keys: ClientKeys);
28
+ /** The service's buckets: `this.storage.default`, or `this.storage.bucket("photos")`. */
29
+ get storage(): Buckets;
30
+ /** A unit test's store in place of the runtime's: `component.storage = new InMemoryBuckets()`. */
31
+ set storage(buckets: Buckets);
22
32
  /** Other services, called as this one, through the runtime: `this.services.service("orders")`. */
23
33
  get services(): Services;
24
34
  /** A unit test's in place of the runtime's: `component.services = new ScriptedServices()`. */
@@ -2,7 +2,9 @@
2
2
  // sweeper delivers it here, retrying a failure with backoff.
3
3
  //
4
4
  // static readonly actions = { remind: action("remind", s.string, (t: Reminder, id) => t.remind(id)) }
5
+ import { clientKeysFor } from "./client-keys.js";
5
6
  import { secretsFor } from "./client.js";
7
+ import { processStorage } from "./storage.js";
6
8
  import { TimedActionEffects } from "./effects/stateless.js";
7
9
  import { servicesFor } from "./services.js";
8
10
  export class TimedAction {
@@ -39,6 +41,24 @@ export class TimedAction {
39
41
  set secrets(store) {
40
42
  this.#secrets = store;
41
43
  }
44
+ #clientKeys;
45
+ /** The service's client keys (1.20): issued for its MCP endpoint, kept by the runtime, each shown once. */
46
+ get clientKeys() {
47
+ return this.#clientKeys ?? clientKeysFor(this.client);
48
+ }
49
+ /** A unit test's in place of the runtime's: `component.clientKeys = new InMemoryClientKeys()`. */
50
+ set clientKeys(keys) {
51
+ this.#clientKeys = keys;
52
+ }
53
+ #storage;
54
+ /** The service's buckets: `this.storage.default`, or `this.storage.bucket("photos")`. */
55
+ get storage() {
56
+ return this.#storage ?? processStorage();
57
+ }
58
+ /** A unit test's store in place of the runtime's: `component.storage = new InMemoryBuckets()`. */
59
+ set storage(buckets) {
60
+ this.#storage = buckets;
61
+ }
42
62
  #services;
43
63
  /** Other services, called as this one, through the runtime: `this.services.service("orders")`. */
44
64
  get services() {
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.11.0";
1
+ export declare const VERSION = "0.13.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Written by scripts/proto.ts from package.json; do not edit. 0.0.0 in the tree, the tag's version in a release.
2
- export const VERSION = "0.11.0";
2
+ export const VERSION = "0.13.0";