ankka 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +1 -0
  2. package/dist/_proto/ankka/protocol/v1/agent_pb.d.ts +35 -0
  3. package/dist/_proto/ankka/protocol/v1/agent_pb.js +8 -1
  4. package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +858 -2
  5. package/dist/_proto/ankka/protocol/v1/client_pb.js +184 -9
  6. package/dist/_proto/ankka/protocol/v1/consumer_pb.d.ts +10 -1
  7. package/dist/_proto/ankka/protocol/v1/consumer_pb.js +1 -1
  8. package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +373 -0
  9. package/dist/_proto/ankka/protocol/v1/discovery_pb.js +81 -16
  10. package/dist/_proto/ankka/protocol/v1/endpoint_pb.d.ts +210 -0
  11. package/dist/_proto/ankka/protocol/v1/endpoint_pb.js +35 -5
  12. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.d.ts +86 -0
  13. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.js +11 -1
  14. package/dist/_proto/ankka/protocol/v1/payload_pb.d.ts +61 -0
  15. package/dist/_proto/ankka/protocol/v1/payload_pb.js +18 -1
  16. package/dist/_proto/ankka/protocol/v1/view_pb.d.ts +70 -1
  17. package/dist/_proto/ankka/protocol/v1/view_pb.js +11 -1
  18. package/dist/_proto/ankka/protocol/v1/wasm_pb.d.ts +12 -0
  19. package/dist/_proto/ankka/protocol/v1/wasm_pb.js +1 -1
  20. package/dist/_proto/ankka/protocol/v1/workflow_pb.d.ts +7 -0
  21. package/dist/_proto/ankka/protocol/v1/workflow_pb.js +1 -1
  22. package/dist/agent.d.ts +15 -1
  23. package/dist/agent.js +20 -0
  24. package/dist/approvals.d.ts +47 -0
  25. package/dist/approvals.js +57 -0
  26. package/dist/autonomous.d.ts +23 -1
  27. package/dist/autonomous.js +30 -1
  28. package/dist/client.d.ts +188 -7
  29. package/dist/client.js +490 -21
  30. package/dist/consumer.d.ts +42 -3
  31. package/dist/consumer.js +35 -1
  32. package/dist/context.d.ts +29 -1
  33. package/dist/context.js +5 -2
  34. package/dist/contract.d.ts +40 -0
  35. package/dist/contract.js +60 -0
  36. package/dist/effects/common.d.ts +7 -0
  37. package/dist/effects/common.js +9 -1
  38. package/dist/effects/eventSourced.d.ts +26 -1
  39. package/dist/effects/eventSourced.js +58 -5
  40. package/dist/effects/keyed.d.ts +33 -0
  41. package/dist/effects/keyed.js +41 -0
  42. package/dist/endpoint.d.ts +10 -1
  43. package/dist/endpoint.js +20 -0
  44. package/dist/eventSourcedEntity.d.ts +8 -3
  45. package/dist/graph.d.ts +24 -2
  46. package/dist/graph.js +27 -1
  47. package/dist/handlers.d.ts +10 -2
  48. package/dist/handlers.js +6 -2
  49. package/dist/index.d.ts +15 -6
  50. package/dist/index.js +12 -3
  51. package/dist/keyedView.d.ts +59 -0
  52. package/dist/keyedView.js +73 -0
  53. package/dist/kinds.js +2 -0
  54. package/dist/mcp.d.ts +57 -0
  55. package/dist/mcp.js +64 -0
  56. package/dist/routes.d.ts +44 -4
  57. package/dist/routes.js +43 -4
  58. package/dist/server/agent.js +12 -4
  59. package/dist/server/discovery.d.ts +17 -1
  60. package/dist/server/discovery.js +182 -1
  61. package/dist/server/eventSourced.d.ts +4 -1
  62. package/dist/server/eventSourced.js +24 -6
  63. package/dist/server/http.d.ts +2 -1
  64. package/dist/server/http.js +80 -4
  65. package/dist/server/stateless.js +42 -3
  66. package/dist/server/workflow.js +5 -1
  67. package/dist/service.d.ts +46 -2
  68. package/dist/service.js +235 -15
  69. package/dist/services.d.ts +137 -0
  70. package/dist/services.js +259 -0
  71. package/dist/socket.d.ts +15 -0
  72. package/dist/socket.js +25 -0
  73. package/dist/spec.d.ts +1 -1
  74. package/dist/spec.js +94 -15
  75. package/dist/standing.d.ts +18 -0
  76. package/dist/standing.js +26 -0
  77. package/dist/startFrom.d.ts +36 -0
  78. package/dist/startFrom.js +51 -0
  79. package/dist/testkit/index.d.ts +2 -2
  80. package/dist/testkit/index.js +1 -1
  81. package/dist/testkit/integration.js +5 -0
  82. package/dist/testkit/kinds.d.ts +50 -5
  83. package/dist/testkit/kinds.js +208 -35
  84. package/dist/testkit/unit.d.ts +41 -3
  85. package/dist/testkit/unit.js +87 -10
  86. package/dist/timedAction.d.ts +17 -2
  87. package/dist/timedAction.js +33 -1
  88. package/dist/version.d.ts +1 -1
  89. package/dist/version.js +1 -1
  90. package/dist/view.d.ts +48 -1
  91. package/dist/view.js +18 -1
  92. package/dist/workflow.d.ts +18 -1
  93. package/dist/workflow.js +39 -0
  94. package/package.json +3 -2
@@ -4,10 +4,11 @@
4
4
  // reduction is the server's (`materialise.ts`), so a test cannot pass on something the sidecar would
5
5
  // not do.
6
6
  import { create } from "@bufbuild/protobuf";
7
- import { HttpRequestSchema } from "../_proto/ankka/protocol/v1/endpoint_pb.js";
7
+ import { HttpRequestSchema, SocketInSchema } from "../_proto/ankka/protocol/v1/endpoint_pb.js";
8
8
  import { ComponentClient, noClient } from "../client.js";
9
9
  import { binaryCodecs, codecFor } from "../codec.js";
10
- import { commandContext } from "../context.js";
10
+ import { commandContext, eventContext } from "../context.js";
11
+ import { ErrorCode } from "../effects/common.js";
11
12
  import { EventSourcedEntity } from "../eventSourcedEntity.js";
12
13
  import { materialiseEventSourced } from "../materialise.js";
13
14
  import { Ankka } from "../service.js";
@@ -22,19 +23,24 @@ function findHandler(handlers, handler, who) {
22
23
  throw new Error(`${who} has no handler ${JSON.stringify(name)}; declared: ${[...handlers.keys()].sort().join(", ")}`);
23
24
  return found;
24
25
  }
25
- /** Drives one event sourced entity instance through its handlers, folding events as it goes. */
26
26
  export class EventSourcedTestKit {
27
27
  #registered;
28
28
  #entity;
29
29
  #client;
30
+ #mode;
31
+ #here;
30
32
  entityId;
33
+ /** The handover or change to the replication set the last call asked for, if any. */
34
+ lastReplication = undefined;
31
35
  /** The state now, after every call so far. */
32
36
  state;
33
37
  /** The sequence number of the last event, `0n` before any. */
34
38
  sequence = 0n;
35
39
  /** Every event persisted so far, in order. */
36
40
  allEvents = [];
37
- constructor(cls, entityId, client) {
41
+ constructor(cls, entityId, client, mode = { kind: "unreplicated" }) {
42
+ this.#mode = mode;
43
+ this.#here = mode.kind === "unreplicated" ? null : (mode.installation ?? "test");
38
44
  const registry = Ankka.service({ client, log: () => { } })
39
45
  .register(cls)
40
46
  .validate();
@@ -46,15 +52,36 @@ export class EventSourcedTestKit {
46
52
  this.state = this.#entity.emptyState();
47
53
  }
48
54
  /** 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);
55
+ static of(cls, entityId = "test", client = noClient(),
56
+ /**
57
+ * How a replicated entity's copy stands (1.19): `{ kind: "home" }`, where every command runs, or
58
+ * `{ kind: "follower", home }`, where a command that may persist is refused `NOT_HOME` and a query is
59
+ * answered from what `replay` gave it. Not replicated by default.
60
+ */
61
+ mode = { kind: "unreplicated" }) {
62
+ return new EventSourcedTestKit(cls, entityId, client, mode);
63
+ }
64
+ /**
65
+ * Applies `events` as history the copy already holds, written at `writtenAt` (this installation by
66
+ * default): for a follower, what it has mirrored from its home.
67
+ */
68
+ replay(events, writtenAt = this.#here) {
69
+ for (const event of events) {
70
+ this.sequence += 1n;
71
+ this.state = this.#entity.applyEvent(this.state, event, eventContext(writtenAt, writtenAt === this.#here, this.sequence));
72
+ }
51
73
  }
52
74
  /** Sends a command or query and returns what its effect amounts to. */
53
75
  async call(handler, input, metadata = {}) {
54
76
  const ref = findHandler(this.#registered.handlers, handler, this.#registered.cls.name);
77
+ if (this.#mode.kind === "follower" && !ref.readOnly) {
78
+ // The runtime forwards such a command to the home; here there is no home to forward to.
79
+ const error = { message: `the home of '${this.entityId}' is '${this.#mode.home}'; a follower handles no command that may persist`, code: ErrorCode.NotHome };
80
+ return Object.freeze({ events: [], newState: this.state, retention: null, reply: undefined, error, noReply: false });
81
+ }
55
82
  const inputCodec = ref.input ? codecFor(ref.input) : undefined;
56
83
  const wireInput = inputCodec ? roundTrip(inputCodec, input) : undefined;
57
- const context = commandContext(this.#registered.id, this.entityId, this.sequence, metadata);
84
+ const context = commandContext(this.#registered.id, this.entityId, this.sequence, metadata, undefined, this.#here, this.#mode.kind !== "follower");
58
85
  this.#entity._bindCommand(this.state, context, this.#client.withMetadata(metadata));
59
86
  let effect;
60
87
  try {
@@ -65,7 +92,20 @@ export class EventSourcedTestKit {
65
92
  }
66
93
  if (ref.readOnly && effect.kind !== "read-only")
67
94
  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));
95
+ let applied = this.sequence;
96
+ const m = materialiseEventSourced(effect, this.state, (s, e) => {
97
+ applied += 1n;
98
+ return this.#entity.applyEvent(s, e, eventContext(this.#here, true, applied));
99
+ });
100
+ const replication = effect.kind === "persist" ? effect.replication : undefined;
101
+ if (!m.error && replication && this.#mode.kind === "unreplicated") {
102
+ const error = {
103
+ 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`,
104
+ code: ErrorCode.BadRequest,
105
+ };
106
+ return Object.freeze({ events: [], newState: this.state, retention: null, reply: undefined, error, noReply: false });
107
+ }
108
+ this.lastReplication = m.error ? undefined : replication;
69
109
  const eventCodec = this.#registered.eventCodec;
70
110
  const stateCodec = this.#registered.stateCodec;
71
111
  const events = m.events.map((e) => roundTrip(eventCodec, e));
@@ -119,7 +159,8 @@ export class EndpointTestKit {
119
159
  const rest = withoutQuery.startsWith(prefix) ? withoutQuery.slice(prefix.length) || "/" : withoutQuery;
120
160
  const candidates = [];
121
161
  for (const [id, route] of Object.entries(this.#cls.routes)) {
122
- if (route.method !== method.toUpperCase())
162
+ // A socket route is opened through `socket`, never answered as a GET.
163
+ if ((route.socket ? "SOCKET" : route.method) !== method.toUpperCase())
123
164
  continue;
124
165
  const pattern = new RegExp("^" + route.template.replace(/[.*+?^$()|[\]\\]/g, "\\$&").replace(PLACEHOLDER, "([^/]+)") + "$");
125
166
  const m = pattern.exec(rest);
@@ -156,7 +197,15 @@ export class EndpointTestKit {
156
197
  }
157
198
  const headers = Object.entries(options.headers ?? {}).map(([name, value]) => ({ name, value }));
158
199
  const principal = options.principal
159
- ? { subject: options.principal.subject, name: options.principal.name ?? undefined, email: options.principal.email ?? undefined, emailVerified: options.principal.emailVerified, roles: [...options.principal.roles] }
200
+ ? {
201
+ subject: options.principal.subject,
202
+ name: options.principal.name ?? undefined,
203
+ email: options.principal.email ?? undefined,
204
+ emailVerified: options.principal.emailVerified,
205
+ roles: [...options.principal.roles],
206
+ claims: { ...(options.principal.claims ?? {}) },
207
+ issuer: options.principal.issuer ?? undefined,
208
+ }
160
209
  : undefined;
161
210
  const req = create(HttpRequestSchema, {
162
211
  endpointId: this.#cls.name,
@@ -192,6 +241,34 @@ export class EndpointTestKit {
192
241
  }
193
242
  return new Response(500, "text/plain", new TextEncoder().encode(reply.message.case === "failure" ? reply.message.value.error?.message ?? "failure" : "no reply"));
194
243
  }
244
+ /**
245
+ * Opens a socket route with no runtime: the handler is given `frames` one at a time and then told the
246
+ * socket is closed, as when the client closes it after sending them. Answers what the handler sent, in
247
+ * order, and how it ended: `finished` when it returned, `failed` (with `error`) when it threw.
248
+ */
249
+ async socket(path, frames = [], options = {}) {
250
+ const prepared = this.#request("SOCKET", path, undefined, options);
251
+ if (!prepared)
252
+ throw new Error(`no socket route at ${path}`);
253
+ const requests = [
254
+ create(SocketInSchema, { message: { case: "open", value: prepared.req } }),
255
+ ...frames.map((text) => create(SocketInSchema, { message: { case: "frame", value: { kind: { case: "text", value: text } } } })),
256
+ create(SocketInSchema, { message: { case: "closed", value: { reason: "client" } } }),
257
+ ];
258
+ async function* script() {
259
+ yield* requests;
260
+ }
261
+ const sent = [];
262
+ for await (const out of this.#dispatcher.handleSocket(script())) {
263
+ if (out.message.case === "frame" && out.message.value.kind.case === "text")
264
+ sent.push(out.message.value.kind.value);
265
+ if (out.message.case === "completed")
266
+ return { sent, ended: "finished" };
267
+ if (out.message.case === "failed")
268
+ return { sent, ended: "failed", error: out.message.value.message };
269
+ }
270
+ return { sent, ended: "finished" };
271
+ }
195
272
  /** The frames of an SSE route, each string one frame. */
196
273
  async sse(path, options = {}) {
197
274
  const prepared = this.#request("GET", path, undefined, options);
@@ -1,13 +1,28 @@
1
1
  import type { Metadata } from "./effects/common.ts";
2
- import type { ComponentClient } from "./client.ts";
2
+ import { type ComponentClient, type Secrets } from "./client.ts";
3
3
  import type { HandlerTable } from "./handlers.ts";
4
4
  import { TimedActionEffects } from "./effects/stateless.ts";
5
+ import { type Services } from "./services.ts";
5
6
  export declare abstract class TimedAction {
6
7
  #private;
7
8
  readonly effects: TimedActionEffects;
8
- /** `ankka.timer` is the timer's id, `ankka.attempts` how many times it has fired. */
9
+ /** `ankka.timer` is the timer's id, `ankka.attempts` how many times it has fired, `ankka.due` the due time in epoch milliseconds. */
9
10
  get metadata(): Metadata;
11
+ /**
12
+ * The due time this run is for, from `ankka.due`: the same on every retry of one due time, and for a
13
+ * recurring timer a whole number of periods after the one before. `undefined` on a runtime older
14
+ * than protocol 1.12, which does not send it.
15
+ */
16
+ get dueTime(): Date | undefined;
10
17
  get client(): ComponentClient;
18
+ /** The service's secret store: values kept encrypted in the service's own database, never in a journal or a view. */
19
+ get secrets(): Secrets;
20
+ /** A unit test's store in place of the runtime's: `component.secrets = new InMemorySecrets()`. */
21
+ set secrets(store: Secrets);
22
+ /** Other services, called as this one, through the runtime: `this.services.service("orders")`. */
23
+ get services(): Services;
24
+ /** A unit test's in place of the runtime's: `component.services = new ScriptedServices()`. */
25
+ set services(services: Services);
11
26
  /** @internal */
12
27
  get _kind(): "timed-action";
13
28
  /** @internal */
@@ -2,20 +2,52 @@
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 { secretsFor } from "./client.js";
5
6
  import { TimedActionEffects } from "./effects/stateless.js";
7
+ import { servicesFor } from "./services.js";
6
8
  export class TimedAction {
7
9
  effects = new TimedActionEffects();
8
10
  #metadata = {};
9
11
  #client;
10
- /** `ankka.timer` is the timer's id, `ankka.attempts` how many times it has fired. */
12
+ /** `ankka.timer` is the timer's id, `ankka.attempts` how many times it has fired, `ankka.due` the due time in epoch milliseconds. */
11
13
  get metadata() {
12
14
  return this.#metadata;
13
15
  }
16
+ /**
17
+ * The due time this run is for, from `ankka.due`: the same on every retry of one due time, and for a
18
+ * recurring timer a whole number of periods after the one before. `undefined` on a runtime older
19
+ * than protocol 1.12, which does not send it.
20
+ */
21
+ get dueTime() {
22
+ const raw = this.#metadata["ankka.due"];
23
+ if (raw === undefined || !/^-?\d+$/.test(raw.trim()))
24
+ return undefined;
25
+ const date = new Date(Number(raw.trim()));
26
+ return Number.isNaN(date.getTime()) ? undefined : date;
27
+ }
14
28
  get client() {
15
29
  if (!this.#client)
16
30
  throw new Error("client is only available inside an action");
17
31
  return this.#client;
18
32
  }
33
+ #secrets;
34
+ /** The service's secret store: values kept encrypted in the service's own database, never in a journal or a view. */
35
+ get secrets() {
36
+ return this.#secrets ?? secretsFor(this.client);
37
+ }
38
+ /** A unit test's store in place of the runtime's: `component.secrets = new InMemorySecrets()`. */
39
+ set secrets(store) {
40
+ this.#secrets = store;
41
+ }
42
+ #services;
43
+ /** Other services, called as this one, through the runtime: `this.services.service("orders")`. */
44
+ get services() {
45
+ return this.#services ?? servicesFor(this.client);
46
+ }
47
+ /** A unit test's in place of the runtime's: `component.services = new ScriptedServices()`. */
48
+ set services(services) {
49
+ this.#services = services;
50
+ }
19
51
  /** @internal */
20
52
  get _kind() {
21
53
  return "timed-action";
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.10.0";
1
+ export declare const VERSION = "0.12.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.10.0";
2
+ export const VERSION = "0.12.0";
package/dist/view.d.ts CHANGED
@@ -1,9 +1,34 @@
1
+ import type { StartFrom } from "./startFrom.ts";
2
+ import type { Contract } from "./contract.ts";
1
3
  import type { Shape } from "./codec.ts";
2
4
  import type { Metadata } from "./effects/common.ts";
3
5
  import type { ComponentClient } from "./client.ts";
4
6
  import type { ComponentRef } from "./client.ts";
5
7
  import { ViewEffects, type ViewEffect } from "./effects/stateless.ts";
8
+ import type { Standing } from "./standing.ts";
6
9
  type MaybePromise<T> = T | Promise<T>;
10
+ /**
11
+ * A question a view can be asked by name: one SQL statement over the view's own table, whose values
12
+ * are the `:name`s it holds. The platform checks the statement when the service starts — one that is
13
+ * not a single read of the view's own table stops the service, naming the view and the query — and
14
+ * binds each value as a parameter, never as part of the text. The answer is rows of the view's row
15
+ * shape, read from the statement's `payload` column.
16
+ */
17
+ export interface DeclaredQuery {
18
+ readonly name: string;
19
+ readonly statement: string;
20
+ /** Whether it may be watched: its statement selects `row_key` beside `payload`, with no limit and no aggregate. */
21
+ readonly watched?: boolean;
22
+ }
23
+ /**
24
+ * Declares a query for a view's `static declared`. The SDK sends the statement as written.
25
+ * `{ watched: true }` declares that it may also be watched, which the platform checks at start.
26
+ */
27
+ export declare function declaredQuery(name: string, statement: string, options?: {
28
+ readonly watched?: boolean;
29
+ }): DeclaredQuery;
30
+ /** The table a view's rows are kept in, for a declared query's statement to name. */
31
+ export declare function tableOf(componentId: string): string;
7
32
  export declare abstract class View<E, Row> {
8
33
  #private;
9
34
  readonly effects: ViewEffects<Row>;
@@ -13,6 +38,8 @@ export declare abstract class View<E, Row> {
13
38
  get metadata(): Metadata;
14
39
  /** The id of the source instance the change came from. */
15
40
  get subject(): string;
41
+ /** Of a change from a workflow: where it stood once the effect that recorded the state was applied. `undefined` for a change from an entity or a topic. */
42
+ get standing(): Standing | undefined;
16
43
  get client(): ComponentClient;
17
44
  /** @internal */
18
45
  get _kind(): "view";
@@ -21,7 +48,7 @@ export declare abstract class View<E, Row> {
21
48
  /** The source was deleted. The row goes with it unless this says otherwise — an order history keeps a checked-out cart's row. */
22
49
  onDelete(): MaybePromise<ViewEffect<Row>>;
23
50
  /** @internal */
24
- _bind(row: Row | null, metadata: Metadata, client: ComponentClient): void;
51
+ _bind(row: Row | null, metadata: Metadata, client: ComponentClient, standing?: Standing): void;
25
52
  }
26
53
  export interface ViewClass<E = unknown, Row = unknown, C extends View<E, Row> = View<E, Row>> {
27
54
  new (): C;
@@ -29,9 +56,29 @@ export interface ViewClass<E = unknown, Row = unknown, C extends View<E, Row> =
29
56
  /** The component whose changes feed the view, or `topic`. */
30
57
  readonly source?: ComponentRef;
31
58
  readonly topic?: string;
59
+ /** Where a topic source starts the first time its group reads the topic. */
60
+ readonly startFrom?: StartFrom;
61
+ /**
62
+ * Raised to have the view emptied and read again: a topic from its start position, under a group of
63
+ * its own, as far back as the broker retains; an entity from its first event or state. Absent is 1.
64
+ */
65
+ readonly version?: number;
66
+ /** The contract the topic read is expected to carry, checked at start against the project's declaration. */
67
+ readonly contract?: Contract;
68
+ /** The declared broker the topic read is on; absent is the installation's. */
69
+ readonly broker?: string;
70
+ /**
71
+ * Another project's topic (1.15): the id of the project whose topic `topic` is. The broker serves it
72
+ * while that project grants this service consume on it.
73
+ */
74
+ readonly project?: string;
75
+ /** Whether the partitions an instance holds are handled at once, each in order. */
76
+ readonly parallel?: boolean;
32
77
  readonly events: Shape<E>;
33
78
  readonly row: Shape<Row>;
34
79
  /** The query names the view answers; `get` and `all` by default. */
35
80
  readonly queries?: readonly string[];
81
+ /** The queries the view can be asked by name, each a statement over its own table (protocol 1.13). */
82
+ readonly declared?: readonly DeclaredQuery[];
36
83
  }
37
84
  export {};
package/dist/view.js CHANGED
@@ -9,10 +9,22 @@
9
9
  // onChange(event) { ... return this.effects.updateRow(...) }
10
10
  // }
11
11
  import { ViewEffects } from "./effects/stateless.js";
12
+ /**
13
+ * Declares a query for a view's `static declared`. The SDK sends the statement as written.
14
+ * `{ watched: true }` declares that it may also be watched, which the platform checks at start.
15
+ */
16
+ export function declaredQuery(name, statement, options = {}) {
17
+ return Object.freeze({ name, statement, watched: options.watched ?? false });
18
+ }
19
+ /** The table a view's rows are kept in, for a declared query's statement to name. */
20
+ export function tableOf(componentId) {
21
+ return "ankka_view_" + componentId.replace(/[^A-Za-z0-9]/g, "_");
22
+ }
12
23
  export class View {
13
24
  effects = new ViewEffects();
14
25
  #row = null;
15
26
  #metadata = {};
27
+ #standing = undefined;
16
28
  #client;
17
29
  #bound = false;
18
30
  /** The current row, or `null` when there is none yet. */
@@ -29,6 +41,10 @@ export class View {
29
41
  get subject() {
30
42
  return this.#metadata["ce-subject"] ?? "";
31
43
  }
44
+ /** Of a change from a workflow: where it stood once the effect that recorded the state was applied. `undefined` for a change from an entity or a topic. */
45
+ get standing() {
46
+ return this.#standing;
47
+ }
32
48
  get client() {
33
49
  if (!this.#client)
34
50
  throw new Error("client is only available inside onChange or onDelete");
@@ -43,9 +59,10 @@ export class View {
43
59
  return this.effects.deleteRow();
44
60
  }
45
61
  /** @internal */
46
- _bind(row, metadata, client) {
62
+ _bind(row, metadata, client, standing) {
47
63
  this.#row = row;
48
64
  this.#metadata = metadata;
65
+ this.#standing = standing;
49
66
  this.#client = client;
50
67
  this.#bound = true;
51
68
  }
@@ -1,8 +1,9 @@
1
1
  import type { Shape } from "./codec.ts";
2
2
  import type { CommandContext } from "./context.ts";
3
3
  import type { HandlerTable } from "./handlers.ts";
4
- import type { ComponentClient } from "./client.ts";
4
+ import { type ComponentClient, type Secrets } from "./client.ts";
5
5
  import { StepEffects, WorkflowEffects, type WorkflowSettings } from "./effects/workflow.ts";
6
+ import { type Services } from "./services.ts";
6
7
  export declare abstract class Workflow<S> {
7
8
  #private;
8
9
  /** Inside a command handler. */
@@ -13,6 +14,22 @@ export declare abstract class Workflow<S> {
13
14
  get entityId(): string;
14
15
  get context(): CommandContext;
15
16
  get client(): ComponentClient;
17
+ /**
18
+ * The service's secret store, in a step. A command handler is refused: it would put a database read
19
+ * on the workflow's single-writer path, as an entity's would.
20
+ */
21
+ get secrets(): Secrets;
22
+ /** A unit test's store in place of the runtime's. */
23
+ set secrets(store: Secrets);
24
+ /**
25
+ * Other services, called as this one, in a step. A command handler is refused, as for the secret
26
+ * store: it would block the workflow's other commands behind another service.
27
+ */
28
+ get services(): Services;
29
+ /** A unit test's in place of the runtime's. */
30
+ set services(services: Services);
31
+ /** @internal The server marks a step's binding, which is how a step is told from a command. */
32
+ _enterStep(): void;
16
33
  /** @internal */
17
34
  get _kind(): "workflow";
18
35
  abstract emptyState(): S;
package/dist/workflow.js CHANGED
@@ -5,7 +5,10 @@
5
5
  //
6
6
  // static readonly steps = { reserve: step("reserve", (w: CheckoutWorkflow) => w.reserve()), charge: step("charge", s.int, (w, q) => w.charge(q)) }
7
7
  // static readonly settings = workflowSettings({ steps: { charge: { recovery: { maxRetries: 1, failoverTo: "compensate" } } } })
8
+ import { secretsFor } from "./client.js";
9
+ import { CommandError } from "./effects/common.js";
8
10
  import { StepEffects, WorkflowEffects } from "./effects/workflow.js";
11
+ import { servicesFor } from "./services.js";
9
12
  export class Workflow {
10
13
  /** Inside a command handler. */
11
14
  effects = new WorkflowEffects();
@@ -36,6 +39,41 @@ export class Workflow {
36
39
  throw new Error("client is only available inside a handler or a step");
37
40
  return this.#client;
38
41
  }
42
+ #inStep = false;
43
+ #secrets;
44
+ /**
45
+ * The service's secret store, in a step. A command handler is refused: it would put a database read
46
+ * on the workflow's single-writer path, as an entity's would.
47
+ */
48
+ get secrets() {
49
+ if (!this.#inStep) {
50
+ throw new CommandError({ message: "a workflow reads and keeps a service secret in a step, not in a command handler", code: "BAD_REQUEST" });
51
+ }
52
+ return this.#secrets ?? secretsFor(this.client);
53
+ }
54
+ /** A unit test's store in place of the runtime's. */
55
+ set secrets(store) {
56
+ this.#secrets = store;
57
+ }
58
+ #services;
59
+ /**
60
+ * Other services, called as this one, in a step. A command handler is refused, as for the secret
61
+ * store: it would block the workflow's other commands behind another service.
62
+ */
63
+ get services() {
64
+ if (!this.#inStep) {
65
+ throw new CommandError({ message: "a workflow calls another service in a step, not in a command handler", code: "BAD_REQUEST" });
66
+ }
67
+ return this.#services ?? servicesFor(this.client);
68
+ }
69
+ /** A unit test's in place of the runtime's. */
70
+ set services(services) {
71
+ this.#services = services;
72
+ }
73
+ /** @internal The server marks a step's binding, which is how a step is told from a command. */
74
+ _enterStep() {
75
+ this.#inStep = true;
76
+ }
39
77
  /** @internal */
40
78
  get _kind() {
41
79
  return "workflow";
@@ -57,5 +95,6 @@ export class Workflow {
57
95
  this.#context = undefined;
58
96
  this.#client = undefined;
59
97
  this.#bound = false;
98
+ this.#inStep = false;
60
99
  }
61
100
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ankka",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Build services on ankka in TypeScript: entities, views, workflows, agents and endpoints hosted by the ankka sidecar.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -50,7 +50,8 @@
50
50
  "dependencies": {
51
51
  "@bufbuild/protobuf": "^2.15.0",
52
52
  "@connectrpc/connect": "^2.2.0",
53
- "@connectrpc/connect-node": "^2.2.0"
53
+ "@connectrpc/connect-node": "^2.2.0",
54
+ "canonicalize": "^2.1.0"
54
55
  },
55
56
  "peerDependencies": {
56
57
  "@testcontainers/postgresql": "^12.1.0",