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
@@ -1,7 +1,11 @@
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";
8
+ import type { Standing } from "./standing.ts";
5
9
  type MaybePromise<T> = T | Promise<T>;
6
10
  /** The metadata entry a runtime sets on a consumer's request to say what it speaks: its protocol version. */
7
11
  export declare const PROTOCOL_KEY = "ankka.protocol";
@@ -23,25 +27,60 @@ export declare abstract class Consumer<M, Out = never> {
23
27
  /** The message's metadata: `ce-subject` is the source instance's id, `ankka.sequence` its sequence number. */
24
28
  get metadata(): Metadata;
25
29
  get subject(): string;
30
+ /** 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. */
31
+ get standing(): Standing | undefined;
26
32
  /** The change's sequence number: an event's, or a key value state's revision. `0n` for a topic's message. */
27
33
  get sequenceNumber(): bigint | undefined;
34
+ /** Of a replicated entity's change (1.19): the installation that wrote it. `undefined` otherwise. */
35
+ get installation(): string | undefined;
36
+ /** Whether the change was written at this installation: `false` only for a replicated entity's mirrored change. */
37
+ get localOrigin(): boolean;
28
38
  get client(): ComponentClient;
39
+ /** The service's secret store: values kept encrypted in the service's own database, never in a journal or a view. */
40
+ get secrets(): Secrets;
41
+ /** A unit test's store in place of the runtime's: `component.secrets = new InMemorySecrets()`. */
42
+ set secrets(store: Secrets);
43
+ /** Other services, called as this one, through the runtime: `this.services.service("orders")`. */
44
+ get services(): Services;
45
+ /** A unit test's in place of the runtime's: `component.services = new ScriptedServices()`. */
46
+ set services(services: Services);
29
47
  /** @internal */
30
48
  get _kind(): "consumer";
31
49
  abstract onMessage(message: M): MaybePromise<ConsumerEffect<Out>>;
32
50
  /** The source was deleted. Ignored unless this says otherwise. */
33
51
  onDelete(): MaybePromise<ConsumerEffect<Out>>;
34
52
  /** @internal */
35
- _bind(metadata: Metadata, client: ComponentClient): void;
53
+ _bind(metadata: Metadata, client: ComponentClient, standing?: Standing): void;
36
54
  }
37
55
  export interface ConsumerClass<M = unknown, Out = unknown, C extends Consumer<M, Out> = Consumer<M, Out>> {
38
56
  new (): C;
39
57
  readonly componentId: string;
40
58
  readonly source?: ComponentRef;
41
59
  readonly topic?: string;
60
+ /** Where a topic source starts the first time its group reads the topic. */
61
+ readonly startFrom?: StartFrom;
62
+ /** Raised to read the topic again from the start position, under a group of its own. Absent is 1. */
63
+ readonly version?: number;
64
+ /** The contract the topic read is expected to carry, checked at start against the project's declaration. */
65
+ readonly contract?: Contract;
66
+ /** The declared broker the topic read is on; absent is the installation's. */
67
+ readonly broker?: string;
68
+ /**
69
+ * Another project's topic (1.15): the id of the project whose topic `topic` is. The broker serves it
70
+ * while that project grants this service consume on it.
71
+ */
72
+ readonly project?: string;
73
+ /** Whether the partitions an instance holds are handled at once, each in order. */
74
+ readonly parallel?: boolean;
42
75
  readonly message: Shape<M>;
43
76
  /** The shape of what `produce` sends onward; required with `producesTo`. */
44
77
  readonly out?: Shape<Out>;
45
- readonly producesTo?: string;
78
+ /** The topic produced to: its name, or a `Publication` with the contract stated for it and its broker. */
79
+ readonly producesTo?: string | Publication;
80
+ /**
81
+ * A consumer that publishes is given a replicated entity's changes written at other installations
82
+ * too only when this says so (1.19); otherwise each is published once, where it was written.
83
+ */
84
+ readonly mirroredChanges?: boolean;
46
85
  }
47
86
  export {};
package/dist/consumer.js CHANGED
@@ -1,5 +1,7 @@
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";
3
5
  /** The metadata entry a runtime sets on a consumer's request to say what it speaks: its protocol version. */
4
6
  export const PROTOCOL_KEY = "ankka.protocol";
5
7
  /** Thrown when a consumer answers with several messages, or a record key, to a runtime that would drop them. */
@@ -29,6 +31,7 @@ export function sequenceNumberOf(metadata) {
29
31
  export class Consumer {
30
32
  effects = new ConsumerEffects();
31
33
  #metadata = {};
34
+ #standing = undefined;
32
35
  #client;
33
36
  /** The message's metadata: `ce-subject` is the source instance's id, `ankka.sequence` its sequence number. */
34
37
  get metadata() {
@@ -37,15 +40,45 @@ export class Consumer {
37
40
  get subject() {
38
41
  return this.#metadata["ce-subject"] ?? "";
39
42
  }
43
+ /** 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. */
44
+ get standing() {
45
+ return this.#standing;
46
+ }
40
47
  /** The change's sequence number: an event's, or a key value state's revision. `0n` for a topic's message. */
41
48
  get sequenceNumber() {
42
49
  return sequenceNumberOf(this.#metadata);
43
50
  }
51
+ /** Of a replicated entity's change (1.19): the installation that wrote it. `undefined` otherwise. */
52
+ get installation() {
53
+ return this.#metadata["ankka.installation"];
54
+ }
55
+ /** Whether the change was written at this installation: `false` only for a replicated entity's mirrored change. */
56
+ get localOrigin() {
57
+ return this.#metadata["ankka.local"] !== "false";
58
+ }
44
59
  get client() {
45
60
  if (!this.#client)
46
61
  throw new Error("client is only available inside onMessage or onDelete");
47
62
  return this.#client;
48
63
  }
64
+ #secrets;
65
+ /** The service's secret store: values kept encrypted in the service's own database, never in a journal or a view. */
66
+ get secrets() {
67
+ return this.#secrets ?? secretsFor(this.client);
68
+ }
69
+ /** A unit test's store in place of the runtime's: `component.secrets = new InMemorySecrets()`. */
70
+ set secrets(store) {
71
+ this.#secrets = store;
72
+ }
73
+ #services;
74
+ /** Other services, called as this one, through the runtime: `this.services.service("orders")`. */
75
+ get services() {
76
+ return this.#services ?? servicesFor(this.client);
77
+ }
78
+ /** A unit test's in place of the runtime's: `component.services = new ScriptedServices()`. */
79
+ set services(services) {
80
+ this.#services = services;
81
+ }
49
82
  /** @internal */
50
83
  get _kind() {
51
84
  return "consumer";
@@ -55,8 +88,9 @@ export class Consumer {
55
88
  return this.effects.ignore();
56
89
  }
57
90
  /** @internal */
58
- _bind(metadata, client) {
91
+ _bind(metadata, client, standing) {
59
92
  this.#metadata = metadata;
93
+ this.#standing = standing;
60
94
  this.#client = client;
61
95
  }
62
96
  }
package/dist/context.d.ts CHANGED
@@ -10,8 +10,26 @@ export interface CommandContext {
10
10
  readonly metadata: Metadata;
11
11
  /** The current instant; a seam a test can replace. */
12
12
  now(): Instant;
13
+ /** The installation handling the command, for a replicated entity (1.19); `null` when none is named. */
14
+ readonly installation: string | null;
15
+ /**
16
+ * Whether this installation is the replicated entity's home. A command that may persist runs only at
17
+ * the home, so this is `false` only for a query a follower answers; `true` for an entity not replicated.
18
+ */
19
+ readonly isHome: boolean;
13
20
  }
14
- export declare function commandContext(componentId: string, entityId: string, sequenceNumber: bigint, metadata: Metadata, now?: () => Instant): CommandContext;
21
+ export declare function commandContext(componentId: string, entityId: string, sequenceNumber: bigint, metadata: Metadata, now?: () => Instant, installation?: string | null, isHome?: boolean): CommandContext;
22
+ /**
23
+ * Where the event `applyEvent` is applying was written (1.19): the installation (`null` when none is
24
+ * named; an event from before replication reads as written at the home), whether that was this
25
+ * installation, and its sequence number. The third argument of `applyEvent`.
26
+ */
27
+ export interface EventContext {
28
+ readonly installation: string | null;
29
+ readonly isLocal: boolean;
30
+ readonly sequence: bigint;
31
+ }
32
+ export declare function eventContext(installation: string | null, isLocal: boolean, sequence: bigint): EventContext;
15
33
  export declare function metadataFromProto(proto: ProtoMetadata | undefined): Metadata;
16
34
  export declare function metadataToProto(metadata: Metadata | undefined): {
17
35
  entries: {
@@ -26,6 +44,10 @@ export interface Principal {
26
44
  readonly email: string | null;
27
45
  readonly emailVerified: boolean;
28
46
  readonly roles: readonly string[];
47
+ /** Every other claim of the verified token, as text. */
48
+ readonly claims: Readonly<Record<string, string>>;
49
+ /** The service's own name for the issuer that verified the token, from `ANKKA_AUTH_ISSUERS`. */
50
+ readonly issuer: string | null;
29
51
  }
30
52
  /**
31
53
  * Which workload sent a request, as the platform established it from the client certificate: the gateway
@@ -37,6 +59,12 @@ export type Caller = {
37
59
  readonly kind: "service";
38
60
  readonly project: string;
39
61
  readonly name: string;
62
+ }
63
+ /** A machine registered on an organization, proven by a token the control plane issued (protocol 1.15). */
64
+ | {
65
+ readonly kind: "machine";
66
+ readonly organization: string;
67
+ readonly name: string;
40
68
  } | {
41
69
  readonly kind: "local";
42
70
  };
package/dist/context.js CHANGED
@@ -4,8 +4,11 @@
4
4
  // request per asynchronous context, and work handed to another context cannot see it.
5
5
  import { AsyncLocalStorage } from "node:async_hooks";
6
6
  import { Instant } from "./time.js";
7
- export function commandContext(componentId, entityId, sequenceNumber, metadata, now = Instant.now) {
8
- return Object.freeze({ componentId, entityId, sequenceNumber, metadata, now });
7
+ export function commandContext(componentId, entityId, sequenceNumber, metadata, now = Instant.now, installation = null, isHome = true) {
8
+ return Object.freeze({ componentId, entityId, sequenceNumber, metadata, now, installation, isHome });
9
+ }
10
+ export function eventContext(installation, isLocal, sequence) {
11
+ return Object.freeze({ installation, isLocal, sequence });
9
12
  }
10
13
  export function metadataFromProto(proto) {
11
14
  const out = {};
@@ -0,0 +1,40 @@
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
+ /** Another project's topic (1.15), which that project must grant this service produce on. */
35
+ readonly project?: string;
36
+ }
37
+ /** The first protocol in which a process can read or publish to another project's topic. */
38
+ export declare const GRANTS_PROTOCOL: readonly [number, number];
39
+ /** The first protocol in which a process can declare a contract, a broker or parallel reading. */
40
+ export declare const CONTRACT_PROTOCOL: readonly [number, number];
@@ -0,0 +1,60 @@
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 read or publish to another project's topic. */
58
+ export const GRANTS_PROTOCOL = [1, 15];
59
+ /** The first protocol in which a process can declare a contract, a broker or parallel reading. */
60
+ export const CONTRACT_PROTOCOL = [1, 14];
@@ -9,6 +9,10 @@ export declare const ErrorCode: Readonly<{
9
9
  readonly Conflict: "CONFLICT";
10
10
  readonly Timeout: "TIMEOUT";
11
11
  readonly Unavailable: "UNAVAILABLE";
12
+ /** A workflow a caller waited for failed or was deleted (protocol 1.18); the step and the reason are in `details`. */
13
+ readonly WorkflowFailed: "WORKFLOW_FAILED";
14
+ /** A replicated entity's copy here cannot answer (1.19): its home is unreachable, or the read is outside the replication set. */
15
+ readonly NotHome: "NOT_HOME";
12
16
  }>;
13
17
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
14
18
  export declare function httpStatusOf(code: ErrorCode): number;
@@ -16,10 +20,13 @@ export declare function httpStatusOf(code: ErrorCode): number;
16
20
  export interface ErrorDetail {
17
21
  readonly message: string;
18
22
  readonly code: ErrorCode;
23
+ /** What a caller reads without parsing the message: a failed workflow's `step`, `reason` and `deleted`. */
24
+ readonly details?: Readonly<Record<string, string>>;
19
25
  }
20
26
  /** Thrown by the component client when the callee refused. */
21
27
  export declare class CommandError extends Error {
22
28
  readonly code: ErrorCode;
29
+ readonly details: Readonly<Record<string, string>>;
23
30
  constructor(detail: ErrorDetail);
24
31
  get detail(): ErrorDetail;
25
32
  }
@@ -10,6 +10,10 @@ export const ErrorCode = Object.freeze({
10
10
  Conflict: "CONFLICT",
11
11
  Timeout: "TIMEOUT",
12
12
  Unavailable: "UNAVAILABLE",
13
+ /** A workflow a caller waited for failed or was deleted (protocol 1.18); the step and the reason are in `details`. */
14
+ WorkflowFailed: "WORKFLOW_FAILED",
15
+ /** A replicated entity's copy here cannot answer (1.19): its home is unreachable, or the read is outside the replication set. */
16
+ NotHome: "NOT_HOME",
13
17
  });
14
18
  const HTTP_STATUS = Object.freeze({
15
19
  INTERNAL: 500,
@@ -20,6 +24,8 @@ const HTTP_STATUS = Object.freeze({
20
24
  CONFLICT: 409,
21
25
  TIMEOUT: 504,
22
26
  UNAVAILABLE: 503,
27
+ WORKFLOW_FAILED: 424,
28
+ NOT_HOME: 421,
23
29
  });
24
30
  export function httpStatusOf(code) {
25
31
  return HTTP_STATUS[code];
@@ -27,13 +33,15 @@ export function httpStatusOf(code) {
27
33
  /** Thrown by the component client when the callee refused. */
28
34
  export class CommandError extends Error {
29
35
  code;
36
+ details;
30
37
  constructor(detail) {
31
38
  super(detail.message);
32
39
  this.name = "CommandError";
33
40
  this.code = detail.code;
41
+ this.details = detail.details ?? {};
34
42
  }
35
43
  get detail() {
36
- return { message: this.message, code: this.code };
44
+ return { message: this.message, code: this.code, details: this.details };
37
45
  }
38
46
  }
39
47
  export const Outcome = Object.freeze({
@@ -1,11 +1,21 @@
1
1
  import type { Duration } from "../time.ts";
2
2
  import { done, type Done } from "../schema.ts";
3
3
  import { ErrorCode, Outcome, Retention, type EffectLike, type Metadata, type ReadOnlyLike } from "./common.ts";
4
+ /** What a replicated entity's handler asks of its home beside the events it persists (1.19). */
5
+ export type ReplicationChange = {
6
+ readonly kind: "hand-over";
7
+ readonly to: string;
8
+ } | {
9
+ readonly kind: "set";
10
+ readonly add: readonly string[];
11
+ readonly remove: readonly string[];
12
+ };
4
13
  export interface PersistEffect<S, E, R> extends EffectLike<R> {
5
14
  readonly kind: "persist";
6
15
  readonly events: readonly E[];
7
16
  readonly retention: Retention | null;
8
17
  readonly outcome: Outcome<S, R>;
18
+ readonly replication?: ReplicationChange;
9
19
  }
10
20
  export interface ReadOnlyEffect<S, E, R> extends ReadOnlyLike<R> {
11
21
  readonly kind: "read-only";
@@ -18,11 +28,20 @@ export type EventSourcedEffect<S, E, R> = PersistEffect<S, E, R> | ReadOnlyEffec
18
28
  /** `effects.persist(e)` returns one of these; finish it with `thenReply`, `thenReplyState` or `thenNoReply`. */
19
29
  export declare class PersistBuilder<S, E> {
20
30
  #private;
21
- constructor(events: readonly E[], retention?: Retention | null);
31
+ constructor(events: readonly E[], retention?: Retention | null, replication?: ReplicationChange);
22
32
  /** Delete the entity once these events are persisted. */
23
33
  deleteEntity(): PersistBuilder<S, E>;
24
34
  /** Expire the entity a while after these events are persisted. */
25
35
  expireAfter(after: Duration): PersistBuilder<S, E>;
36
+ /**
37
+ * Persist these events, then hand a replicated entity's home to `installation`, which must be in its
38
+ * replication set. Recorded once the new home holds everything before it.
39
+ */
40
+ handOverTo(installation: string): PersistBuilder<S, E>;
41
+ /** Persist these events, then add `installation` to a replicated entity's replication set. */
42
+ replicateTo(installation: string): PersistBuilder<S, E>;
43
+ /** Persist these events, then take `installation` out of a replicated entity's replication set; it keeps its copy. */
44
+ stopReplicatingTo(installation: string): PersistBuilder<S, E>;
26
45
  /** Reply with a value computed from the state *after* the events. */
27
46
  thenReply<R>(compute: (state: S) => R, metadata?: Metadata): PersistEffect<S, E, R>;
28
47
  /** Reply with the state after the events. */
@@ -46,5 +65,11 @@ export declare class EventSourcedEffects<S, E> {
46
65
  deleteEntity(): PersistBuilder<S, E>;
47
66
  /** Expire the entity after a while, persisting nothing more. */
48
67
  expireAfter(after: Duration): PersistBuilder<S, E>;
68
+ /** Hand a replicated entity's home to `installation`, with no event of its own. */
69
+ handOverTo(installation: string): PersistBuilder<S, E>;
70
+ /** Add `installation` to a replicated entity's replication set, with no event of its own. */
71
+ replicateTo(installation: string): PersistBuilder<S, E>;
72
+ /** Take `installation` out of a replicated entity's replication set; it keeps its copy. */
73
+ stopReplicatingTo(installation: string): PersistBuilder<S, E>;
49
74
  }
50
75
  export { done, type Done };
@@ -1,27 +1,67 @@
1
1
  // The effects an event sourced entity's handlers return: persist these events and then reply from the
2
2
  // state after them, or answer without persisting. Inert values. `ReadOnlyEffect` is a distinct type
3
3
  // with `kind: "read-only"`, which is what lets `query` refuse a persisting handler at compile time.
4
+ var _a;
4
5
  import { done } from "../schema.js";
5
6
  import { ErrorCode, Outcome, Retention } from "./common.js";
6
7
  /** `effects.persist(e)` returns one of these; finish it with `thenReply`, `thenReplyState` or `thenNoReply`. */
7
8
  export class PersistBuilder {
8
9
  #events;
9
10
  #retention;
10
- constructor(events, retention = null) {
11
+ #replication;
12
+ constructor(events, retention = null, replication) {
11
13
  this.#events = events;
12
14
  this.#retention = retention;
15
+ this.#replication = replication;
13
16
  }
14
17
  /** Delete the entity once these events are persisted. */
15
18
  deleteEntity() {
16
- return new PersistBuilder(this.#events, Retention.deleteNow);
19
+ return new _a(this.#events, Retention.deleteNow, this.#replication);
17
20
  }
18
21
  /** Expire the entity a while after these events are persisted. */
19
22
  expireAfter(after) {
20
- return new PersistBuilder(this.#events, Retention.expireAfter(after));
23
+ return new _a(this.#events, Retention.expireAfter(after), this.#replication);
24
+ }
25
+ /**
26
+ * Persist these events, then hand a replicated entity's home to `installation`, which must be in its
27
+ * replication set. Recorded once the new home holds everything before it.
28
+ */
29
+ handOverTo(installation) {
30
+ if (this.#replication)
31
+ throw new Error(`a command asks for one handover or one change to the replication set; it asked for ${JSON.stringify(this.#replication)}`);
32
+ return new _a(this.#events, this.#retention, Object.freeze({ kind: "hand-over", to: installation }));
33
+ }
34
+ /** Persist these events, then add `installation` to a replicated entity's replication set. */
35
+ replicateTo(installation) {
36
+ return this.#setChange([installation], []);
37
+ }
38
+ /** Persist these events, then take `installation` out of a replicated entity's replication set; it keeps its copy. */
39
+ stopReplicatingTo(installation) {
40
+ return this.#setChange([], [installation]);
41
+ }
42
+ #setChange(add, remove) {
43
+ const current = this.#replication;
44
+ if (current?.kind === "hand-over")
45
+ throw new Error(`a command asks for one handover or one change to the replication set; it asked for ${JSON.stringify(current)}`);
46
+ const adding = [...new Set([...(current?.add ?? []), ...add])].sort();
47
+ const removing = [...new Set([...(current?.remove ?? []), ...remove])].sort();
48
+ const both = adding.filter((name) => removing.includes(name));
49
+ if (both.length > 0)
50
+ throw new Error(`a command both adds and removes ${both.join(", ")} in the replication set`);
51
+ return new _a(this.#events, this.#retention, Object.freeze({ kind: "set", add: Object.freeze(adding), remove: Object.freeze(removing) }));
52
+ }
53
+ #effect(outcome) {
54
+ return Object.freeze({
55
+ kind: "persist",
56
+ events: this.#events,
57
+ retention: this.#retention,
58
+ outcome,
59
+ ...(this.#replication ? { replication: this.#replication } : {}),
60
+ });
21
61
  }
22
62
  /** Reply with a value computed from the state *after* the events. */
23
63
  thenReply(compute, metadata) {
24
- return Object.freeze({ kind: "persist", events: this.#events, retention: this.#retention, outcome: Outcome.reply(compute, metadata) });
64
+ return this.#effect(Outcome.reply(compute, metadata));
25
65
  }
26
66
  /** Reply with the state after the events. */
27
67
  thenReplyState() {
@@ -29,9 +69,10 @@ export class PersistBuilder {
29
69
  }
30
70
  /** Persist and answer nothing. */
31
71
  thenNoReply() {
32
- return Object.freeze({ kind: "persist", events: this.#events, retention: this.#retention, outcome: Outcome.noReply() });
72
+ return this.#effect(Outcome.noReply());
33
73
  }
34
74
  }
75
+ _a = PersistBuilder;
35
76
  /** The factory a handler reaches as `this.effects`. */
36
77
  export class EventSourcedEffects {
37
78
  /** Persist one or more events. */
@@ -62,5 +103,17 @@ export class EventSourcedEffects {
62
103
  expireAfter(after) {
63
104
  return new PersistBuilder(Object.freeze([]), Retention.expireAfter(after));
64
105
  }
106
+ /** Hand a replicated entity's home to `installation`, with no event of its own. */
107
+ handOverTo(installation) {
108
+ return new PersistBuilder(Object.freeze([])).handOverTo(installation);
109
+ }
110
+ /** Add `installation` to a replicated entity's replication set, with no event of its own. */
111
+ replicateTo(installation) {
112
+ return new PersistBuilder(Object.freeze([])).replicateTo(installation);
113
+ }
114
+ /** Take `installation` out of a replicated entity's replication set; it keeps its copy. */
115
+ stopReplicatingTo(installation) {
116
+ return new PersistBuilder(Object.freeze([])).stopReplicatingTo(installation);
117
+ }
65
118
  }
66
119
  export { done };
@@ -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][];
@@ -0,0 +1,41 @@
1
+ // Effects for a keyed view: the rows one change writes and deletes, each named by its key.
2
+ function rows(changes) {
3
+ return Object.freeze({ kind: "rows", changes: Object.freeze([...changes]) });
4
+ }
5
+ function checkedKey(key) {
6
+ if (typeof key !== "string" || key === "")
7
+ throw new TypeError("a row key is a string of one character or more");
8
+ return key;
9
+ }
10
+ export class KeyedViewEffects {
11
+ updateRow(key, row) {
12
+ return rows([{ key: checkedKey(key), row }]);
13
+ }
14
+ deleteRow(key) {
15
+ return rows([{ key: checkedKey(key), deleted: true }]);
16
+ }
17
+ /** Several rows written, each under its key. */
18
+ updateRows(entries) {
19
+ return rows([...entries].map(([key, row]) => ({ key: checkedKey(key), row })));
20
+ }
21
+ deleteRows(keys) {
22
+ return rows([...keys].map((key) => ({ key: checkedKey(key), deleted: true })));
23
+ }
24
+ ignore() {
25
+ return rows([]);
26
+ }
27
+ /** Several effects as one, in the order given. */
28
+ all(...effects) {
29
+ return rows(effects.flatMap((e) => e.changes));
30
+ }
31
+ }
32
+ /**
33
+ * The final change for each key, a later change to a key winning, keys in the order they first
34
+ * appear; `undefined` is a deletion. How the test kit applies an effect, as the runtime does.
35
+ */
36
+ export function reduceRowChanges(changes) {
37
+ const last = new Map();
38
+ for (const change of changes)
39
+ last.set(change.key, "deleted" in change ? undefined : change.row);
40
+ return [...last.entries()];
41
+ }
@@ -1,12 +1,21 @@
1
- import type { ComponentClient } from "./client.ts";
1
+ import { type ComponentClient, type Secrets } from "./client.ts";
2
2
  import { type RequestContext } from "./context.ts";
3
3
  import type { Acl, RouteTable } from "./routes.ts";
4
+ import { type Services } from "./services.ts";
4
5
  export declare abstract class Endpoint {
5
6
  #private;
6
7
  /** The request being handled: path parameters, query, headers, principal. Request-scoped. */
7
8
  get request(): RequestContext;
8
9
  /** The component client, scoped to the current request's trace so the sidecar records child spans. */
9
10
  get client(): ComponentClient;
11
+ /** The service's secret store: values kept encrypted in the service's own database, never in a journal or a view. */
12
+ get secrets(): Secrets;
13
+ /** A unit test's store in place of the runtime's: `component.secrets = new InMemorySecrets()`. */
14
+ set secrets(store: Secrets);
15
+ /** Other services, called as this one, through the runtime: `this.services.service("orders")`. */
16
+ get services(): Services;
17
+ /** A unit test's in place of the runtime's: `component.services = new ScriptedServices()`. */
18
+ set services(services: Services);
10
19
  /** @internal */
11
20
  _bindClient(client: ComponentClient | undefined): void;
12
21
  }