ankka 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +30 -0
  2. package/dist/_proto/ankka/protocol/v1/agent_pb.d.ts +350 -0
  3. package/dist/_proto/ankka/protocol/v1/agent_pb.js +90 -0
  4. package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +254 -0
  5. package/dist/_proto/ankka/protocol/v1/client_pb.js +47 -0
  6. package/dist/_proto/ankka/protocol/v1/consumer_pb.d.ts +105 -0
  7. package/dist/_proto/ankka/protocol/v1/consumer_pb.js +28 -0
  8. package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +598 -0
  9. package/dist/_proto/ankka/protocol/v1/discovery_pb.js +175 -0
  10. package/dist/_proto/ankka/protocol/v1/endpoint_pb.d.ts +224 -0
  11. package/dist/_proto/ankka/protocol/v1/endpoint_pb.js +44 -0
  12. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.d.ts +212 -0
  13. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.js +47 -0
  14. package/dist/_proto/ankka/protocol/v1/key_value_pb.d.ts +154 -0
  15. package/dist/_proto/ankka/protocol/v1/key_value_pb.js +36 -0
  16. package/dist/_proto/ankka/protocol/v1/payload_pb.d.ts +276 -0
  17. package/dist/_proto/ankka/protocol/v1/payload_pb.js +110 -0
  18. package/dist/_proto/ankka/protocol/v1/timed_action_pb.d.ts +75 -0
  19. package/dist/_proto/ankka/protocol/v1/timed_action_pb.js +23 -0
  20. package/dist/_proto/ankka/protocol/v1/view_pb.d.ts +93 -0
  21. package/dist/_proto/ankka/protocol/v1/view_pb.js +23 -0
  22. package/dist/_proto/ankka/protocol/v1/workflow_pb.d.ts +298 -0
  23. package/dist/_proto/ankka/protocol/v1/workflow_pb.js +67 -0
  24. package/dist/agent.d.ts +27 -0
  25. package/dist/agent.js +36 -0
  26. package/dist/client.d.ts +92 -0
  27. package/dist/client.js +239 -0
  28. package/dist/codec.d.ts +44 -0
  29. package/dist/codec.js +215 -0
  30. package/dist/consumer.d.ts +31 -0
  31. package/dist/consumer.js +32 -0
  32. package/dist/context.d.ts +61 -0
  33. package/dist/context.js +75 -0
  34. package/dist/effects/agent.d.ts +37 -0
  35. package/dist/effects/agent.js +48 -0
  36. package/dist/effects/common.d.ts +68 -0
  37. package/dist/effects/common.js +55 -0
  38. package/dist/effects/eventSourced.d.ts +50 -0
  39. package/dist/effects/eventSourced.js +66 -0
  40. package/dist/effects/keyValue.d.ts +36 -0
  41. package/dist/effects/keyValue.js +47 -0
  42. package/dist/effects/stateless.d.ts +40 -0
  43. package/dist/effects/stateless.js +35 -0
  44. package/dist/effects/workflow.d.ts +99 -0
  45. package/dist/effects/workflow.js +83 -0
  46. package/dist/endpoint.d.ts +21 -0
  47. package/dist/endpoint.js +29 -0
  48. package/dist/eventSourcedEntity.d.ts +40 -0
  49. package/dist/eventSourcedEntity.js +66 -0
  50. package/dist/handlers.d.ts +55 -0
  51. package/dist/handlers.js +64 -0
  52. package/dist/index.d.ts +29 -0
  53. package/dist/index.js +39 -0
  54. package/dist/json.d.ts +21 -0
  55. package/dist/json.js +362 -0
  56. package/dist/keyValueEntity.d.ts +29 -0
  57. package/dist/keyValueEntity.js +52 -0
  58. package/dist/kinds.d.ts +8 -0
  59. package/dist/kinds.js +41 -0
  60. package/dist/materialise.d.ts +33 -0
  61. package/dist/materialise.js +55 -0
  62. package/dist/routes.d.ts +71 -0
  63. package/dist/routes.js +79 -0
  64. package/dist/schema.d.ts +112 -0
  65. package/dist/schema.js +200 -0
  66. package/dist/server/agent.d.ts +14 -0
  67. package/dist/server/agent.js +113 -0
  68. package/dist/server/discovery.d.ts +5 -0
  69. package/dist/server/discovery.js +21 -0
  70. package/dist/server/eventSourced.d.ts +12 -0
  71. package/dist/server/eventSourced.js +168 -0
  72. package/dist/server/http.d.ts +10 -0
  73. package/dist/server/http.js +153 -0
  74. package/dist/server/keyValue.d.ts +5 -0
  75. package/dist/server/keyValue.js +122 -0
  76. package/dist/server/payloads.d.ts +6 -0
  77. package/dist/server/payloads.js +11 -0
  78. package/dist/server/queue.d.ts +10 -0
  79. package/dist/server/queue.js +67 -0
  80. package/dist/server/server.d.ts +34 -0
  81. package/dist/server/server.js +101 -0
  82. package/dist/server/stateless.d.ts +9 -0
  83. package/dist/server/stateless.js +108 -0
  84. package/dist/server/workflow.d.ts +5 -0
  85. package/dist/server/workflow.js +244 -0
  86. package/dist/service.d.ts +147 -0
  87. package/dist/service.js +384 -0
  88. package/dist/spec.d.ts +7 -0
  89. package/dist/spec.js +106 -0
  90. package/dist/testkit/index.d.ts +3 -0
  91. package/dist/testkit/index.js +6 -0
  92. package/dist/testkit/integration.d.ts +60 -0
  93. package/dist/testkit/integration.js +284 -0
  94. package/dist/testkit/kinds.d.ts +133 -0
  95. package/dist/testkit/kinds.js +398 -0
  96. package/dist/testkit/unit.d.ts +54 -0
  97. package/dist/testkit/unit.js +224 -0
  98. package/dist/time.d.ts +73 -0
  99. package/dist/time.js +275 -0
  100. package/dist/timedAction.d.ts +20 -0
  101. package/dist/timedAction.js +28 -0
  102. package/dist/version.d.ts +1 -0
  103. package/dist/version.js +2 -0
  104. package/dist/view.d.ts +37 -0
  105. package/dist/view.js +52 -0
  106. package/dist/workflow.d.ts +33 -0
  107. package/dist/workflow.js +61 -0
  108. package/package.json +75 -0
@@ -0,0 +1,92 @@
1
+ import { type Client as ConnectClient, type Transport } from "@connectrpc/connect";
2
+ import { Client } from "./_proto/ankka/protocol/v1/client_pb.ts";
3
+ import { jsonCodec, type Shape } from "./codec.ts";
4
+ import { type Metadata } from "./effects/common.ts";
5
+ import type { HandlerRef } from "./handlers.ts";
6
+ import { type ComponentKind } from "./kinds.ts";
7
+ import type { Duration } from "./time.ts";
8
+ /** A component class as the typed client sees it: an id and, on its prototype, its kind. */
9
+ export interface ComponentRef {
10
+ readonly componentId: string;
11
+ readonly prototype: {
12
+ readonly _kind: ComponentKind;
13
+ };
14
+ }
15
+ interface Connection {
16
+ address: string;
17
+ transport: Transport | undefined;
18
+ stub: ConnectClient<typeof Client> | undefined;
19
+ }
20
+ /** One call, ready to invoke or stream. */
21
+ export declare class Invocation<I, R> {
22
+ #private;
23
+ constructor(connection: Connection, metadata: Metadata, kind: ComponentKind, componentId: string, entityId: string, name: string, input: Shape<I> | undefined, reply: Shape<R> | undefined);
24
+ /** Invokes and decodes the reply; a refusal rejects with `CommandError`. */
25
+ invoke(input?: I): Promise<R>;
26
+ /** Invokes a streaming handler; yields tokens in order; a failure rejects with `CommandError`. */
27
+ stream(input?: I): AsyncIterable<string>;
28
+ }
29
+ /** Calls on one component instance, by wire name. */
30
+ export declare class Calls {
31
+ #private;
32
+ constructor(connection: Connection, metadata: Metadata, kind: ComponentKind, componentId: string, entityId: string);
33
+ /** `call("add-item", LineItem, Done)`: the wire name, the input shape (omit for none) and the reply shape (omit for `done`). */
34
+ call<I = undefined, R = unknown>(name: string, input?: Shape<I>, reply?: Shape<R>): Invocation<I, R>;
35
+ }
36
+ /** Calls on one component instance through its class's handler table, fully typed. */
37
+ export declare class TypedCalls<C> {
38
+ #private;
39
+ constructor(calls: Calls);
40
+ /** `call(ShoppingCartEntity.handlers.addItem)`: the handler's wire name and shapes come from the declaration. */
41
+ call<I, R>(handler: HandlerRef<C, I, R, any>): Invocation<I, R>;
42
+ }
43
+ /** View queries: rows come back as a JSON array decoded with the row shape. */
44
+ export declare class Views {
45
+ #private;
46
+ constructor(connection: Connection);
47
+ get<Row>(viewId: string, key: string, row: Shape<Row>): Promise<Row | null>;
48
+ all<Row>(viewId: string, row: Shape<Row>): Promise<Row[]>;
49
+ query<Row>(viewId: string, name: string, key: string | null, row: Shape<Row>): Promise<Row[]>;
50
+ }
51
+ /** What a timer fires: a component class and one of its handlers, or the same by name. */
52
+ export type TimerTarget = {
53
+ readonly component: ComponentRef;
54
+ readonly handler: HandlerRef<any, any, any, any>;
55
+ readonly entityId?: string;
56
+ } | {
57
+ readonly kind: ComponentKind;
58
+ readonly componentId: string;
59
+ readonly name: string;
60
+ readonly input?: Shape<any>;
61
+ readonly entityId?: string;
62
+ };
63
+ export declare class Timers {
64
+ #private;
65
+ constructor(connection: Connection);
66
+ /** Schedules `target` after `delay`. Scheduling twice under one id replaces the earlier schedule. */
67
+ schedule(timerId: string, delay: Duration, target: TimerTarget, input?: unknown): Promise<void>;
68
+ cancel(timerId: string): Promise<void>;
69
+ }
70
+ export declare class ComponentClient {
71
+ #private;
72
+ readonly views: Views;
73
+ readonly timers: Timers;
74
+ constructor(address?: string, connection?: Connection, metadata?: Metadata);
75
+ /** Points every client sharing this connection at a new sidecar address (the integration testkit's mapped port). */
76
+ reconnect(address: string): void;
77
+ /** The sidecar address this client dials. */
78
+ get address(): string;
79
+ /** A client whose calls carry `metadata` (the current request's trace), sharing this one's connection. */
80
+ withMetadata(metadata: Metadata): ComponentClient;
81
+ forEventSourcedEntity(componentId: string, entityId: string): Calls;
82
+ forKeyValueEntity(componentId: string, entityId: string): Calls;
83
+ forWorkflow(componentId: string, workflowId: string): Calls;
84
+ forAgent(componentId: string, sessionId: string): Calls;
85
+ /** Typed calls through a component class's handler table: `client.of(ShoppingCartEntity, cartId).call(ShoppingCartEntity.handlers.addItem).invoke(item)`. */
86
+ of<C>(component: ComponentRef & {
87
+ new (): C;
88
+ }, entityId: string): TypedCalls<C>;
89
+ }
90
+ /** A client for unit tests: every call throws, naming what was attempted. */
91
+ export declare function noClient(): ComponentClient;
92
+ export { jsonCodec };
package/dist/client.js ADDED
@@ -0,0 +1,239 @@
1
+ // The component client: how a handler calls other components, queries views and schedules timers. Every
2
+ // call is a request on the sidecar's `Client` service at `ANKKA_SIDECAR_ADDRESS`; the sidecar routes it.
3
+ // One channel per process, opened on first use so the integration testkit can learn the sidecar's
4
+ // mapped port after construction. A client handed to a handler is scoped to that request's metadata,
5
+ // so the sidecar records the call as a child span.
6
+ import { createClient } from "@connectrpc/connect";
7
+ import { createGrpcTransport } from "@connectrpc/connect-node";
8
+ import { Client } from "./_proto/ankka/protocol/v1/client_pb.js";
9
+ import { codecFor, jsonCodec, isCodec, textCodecs, binaryCodecs, codecForManifest } from "./codec.js";
10
+ import { CommandError } from "./effects/common.js";
11
+ import { decodeJsonValue, reviver } from "./json.js";
12
+ import { errorCodeFromProto, kindToProto } from "./kinds.js";
13
+ import { metadataToProto } from "./context.js";
14
+ import { encodePayload, decodePayload, EMPTY_PAYLOAD } from "./server/payloads.js";
15
+ function stubOf(connection) {
16
+ if (!connection.stub) {
17
+ connection.transport = createGrpcTransport({ baseUrl: `http://${connection.address}` });
18
+ connection.stub = createClient(Client, connection.transport);
19
+ }
20
+ return connection.stub;
21
+ }
22
+ function errorOf(error) {
23
+ return { message: error.message, code: errorCodeFromProto(error.code) };
24
+ }
25
+ function inputPayload(shape, input) {
26
+ if (shape === undefined) {
27
+ if (input !== undefined)
28
+ throw new TypeError("this handler takes no input");
29
+ return EMPTY_PAYLOAD;
30
+ }
31
+ return encodePayload(codecFor(shape), input);
32
+ }
33
+ function decodeReply(shape, payload) {
34
+ if (shape !== undefined)
35
+ return decodePayload(codecFor(shape), payload);
36
+ // No reply shape named: a primitive by its manifest, `done` otherwise.
37
+ const codec = payload ? codecForManifest(payload.manifest) : undefined;
38
+ return (codec ?? binaryCodecs.done).decode(payload?.data ?? new Uint8Array());
39
+ }
40
+ /** One call, ready to invoke or stream. */
41
+ export class Invocation {
42
+ #connection;
43
+ #metadata;
44
+ #kind;
45
+ #componentId;
46
+ #entityId;
47
+ #name;
48
+ #input;
49
+ #reply;
50
+ constructor(connection, metadata, kind, componentId, entityId, name, input, reply) {
51
+ this.#connection = connection;
52
+ this.#metadata = metadata;
53
+ this.#kind = kind;
54
+ this.#componentId = componentId;
55
+ this.#entityId = entityId;
56
+ this.#name = name;
57
+ this.#input = input;
58
+ this.#reply = reply;
59
+ }
60
+ #request(input) {
61
+ return {
62
+ kind: kindToProto(this.#kind),
63
+ componentId: this.#componentId,
64
+ entityId: this.#entityId,
65
+ name: this.#name,
66
+ payload: inputPayload(this.#input, input),
67
+ metadata: metadataToProto(this.#metadata),
68
+ };
69
+ }
70
+ /** Invokes and decodes the reply; a refusal rejects with `CommandError`. */
71
+ async invoke(input) {
72
+ const answer = await stubOf(this.#connection).invoke(this.#request(input));
73
+ switch (answer.result.case) {
74
+ case "reply":
75
+ return decodeReply(this.#reply, answer.result.value.payload);
76
+ case "error":
77
+ throw new CommandError(errorOf(answer.result.value));
78
+ default:
79
+ throw new CommandError({ message: "the sidecar answered nothing", code: "INTERNAL" });
80
+ }
81
+ }
82
+ /** Invokes a streaming handler; yields tokens in order; a failure rejects with `CommandError`. */
83
+ async *stream(input) {
84
+ for await (const token of stubOf(this.#connection).invokeStream(this.#request(input))) {
85
+ switch (token.token.case) {
86
+ case "text":
87
+ yield token.token.value;
88
+ break;
89
+ case "completed":
90
+ return;
91
+ case "failed":
92
+ throw new CommandError(errorOf(token.token.value));
93
+ }
94
+ }
95
+ }
96
+ }
97
+ /** Calls on one component instance, by wire name. */
98
+ export class Calls {
99
+ #connection;
100
+ #metadata;
101
+ #kind;
102
+ #componentId;
103
+ #entityId;
104
+ constructor(connection, metadata, kind, componentId, entityId) {
105
+ this.#connection = connection;
106
+ this.#metadata = metadata;
107
+ this.#kind = kind;
108
+ this.#componentId = componentId;
109
+ this.#entityId = entityId;
110
+ }
111
+ /** `call("add-item", LineItem, Done)`: the wire name, the input shape (omit for none) and the reply shape (omit for `done`). */
112
+ call(name, input, reply) {
113
+ return new Invocation(this.#connection, this.#metadata, this.#kind, this.#componentId, this.#entityId, name, input, reply);
114
+ }
115
+ }
116
+ /** Calls on one component instance through its class's handler table, fully typed. */
117
+ export class TypedCalls {
118
+ #calls;
119
+ constructor(calls) {
120
+ this.#calls = calls;
121
+ }
122
+ /** `call(ShoppingCartEntity.handlers.addItem)`: the handler's wire name and shapes come from the declaration. */
123
+ call(handler) {
124
+ return this.#calls.call(handler.name, handler.input, handler.reply);
125
+ }
126
+ }
127
+ /** View queries: rows come back as a JSON array decoded with the row shape. */
128
+ export class Views {
129
+ #connection;
130
+ constructor(connection) {
131
+ this.#connection = connection;
132
+ }
133
+ async get(viewId, key, row) {
134
+ const rows = await this.query(viewId, "get", key, row);
135
+ return rows[0] ?? null;
136
+ }
137
+ async all(viewId, row) {
138
+ return this.query(viewId, "all", null, row);
139
+ }
140
+ async query(viewId, name, key, row) {
141
+ const answer = await stubOf(this.#connection).query({ viewId, name, payload: encodePayload(textCodecs.string, key ?? "") });
142
+ switch (answer.result.case) {
143
+ case "rows": {
144
+ const text = new TextDecoder().decode(answer.result.value.data);
145
+ const documents = JSON.parse(text, reviver);
146
+ if (!Array.isArray(documents))
147
+ throw new CommandError({ message: "the view answered something other than a JSON array", code: "INTERNAL" });
148
+ if (isCodec(row)) {
149
+ const utf8 = new TextEncoder();
150
+ return documents.map((d) => row.decode(utf8.encode(JSON.stringify(d, (_k, v) => (typeof v === "bigint" ? JSON.rawJSON(v.toString()) : v)))));
151
+ }
152
+ return documents.map((d) => decodeJsonValue(row, d));
153
+ }
154
+ case "error":
155
+ throw new CommandError(errorOf(answer.result.value));
156
+ default:
157
+ throw new CommandError({ message: "the sidecar answered nothing", code: "INTERNAL" });
158
+ }
159
+ }
160
+ }
161
+ export class Timers {
162
+ #connection;
163
+ constructor(connection) {
164
+ this.#connection = connection;
165
+ }
166
+ /** Schedules `target` after `delay`. Scheduling twice under one id replaces the earlier schedule. */
167
+ async schedule(timerId, delay, target, input) {
168
+ const t = "component" in target
169
+ ? { kind: target.component.prototype._kind, componentId: target.component.componentId, name: target.handler.name, input: target.handler.input, entityId: target.entityId }
170
+ : target;
171
+ await stubOf(this.#connection).schedule({
172
+ timerId,
173
+ delayMillis: BigInt(delay.toMillis()),
174
+ kind: kindToProto(t.kind),
175
+ componentId: t.componentId,
176
+ ...(t.entityId !== undefined ? { entityId: t.entityId } : {}),
177
+ name: t.name,
178
+ payload: inputPayload(t.input, input),
179
+ });
180
+ }
181
+ async cancel(timerId) {
182
+ await stubOf(this.#connection).cancel({ timerId });
183
+ }
184
+ }
185
+ export class ComponentClient {
186
+ #connection;
187
+ #metadata;
188
+ views;
189
+ timers;
190
+ constructor(address = process.env.ANKKA_SIDECAR_ADDRESS ?? "127.0.0.1:9011", connection, metadata = {}) {
191
+ this.#connection = connection ?? { address, transport: undefined, stub: undefined };
192
+ this.#metadata = metadata;
193
+ this.views = new Views(this.#connection);
194
+ this.timers = new Timers(this.#connection);
195
+ }
196
+ /** Points every client sharing this connection at a new sidecar address (the integration testkit's mapped port). */
197
+ reconnect(address) {
198
+ this.#connection.address = address;
199
+ this.#connection.transport = undefined;
200
+ this.#connection.stub = undefined;
201
+ }
202
+ /** The sidecar address this client dials. */
203
+ get address() {
204
+ return this.#connection.address;
205
+ }
206
+ /** A client whose calls carry `metadata` (the current request's trace), sharing this one's connection. */
207
+ withMetadata(metadata) {
208
+ return new ComponentClient(this.#connection.address, this.#connection, metadata);
209
+ }
210
+ forEventSourcedEntity(componentId, entityId) {
211
+ return new Calls(this.#connection, this.#metadata, "event-sourced", componentId, entityId);
212
+ }
213
+ forKeyValueEntity(componentId, entityId) {
214
+ return new Calls(this.#connection, this.#metadata, "key-value", componentId, entityId);
215
+ }
216
+ forWorkflow(componentId, workflowId) {
217
+ return new Calls(this.#connection, this.#metadata, "workflow", componentId, workflowId);
218
+ }
219
+ forAgent(componentId, sessionId) {
220
+ return new Calls(this.#connection, this.#metadata, "agent", componentId, sessionId);
221
+ }
222
+ /** Typed calls through a component class's handler table: `client.of(ShoppingCartEntity, cartId).call(ShoppingCartEntity.handlers.addItem).invoke(item)`. */
223
+ of(component, entityId) {
224
+ return new TypedCalls(new Calls(this.#connection, this.#metadata, component.prototype._kind, component.componentId, entityId));
225
+ }
226
+ }
227
+ /** A client for unit tests: every call throws, naming what was attempted. */
228
+ export function noClient() {
229
+ const fail = () => {
230
+ throw new Error("this component called another component; the unit testkit has no sidecar. Pass a stub ComponentClient to the kit.");
231
+ };
232
+ const connection = {
233
+ address: "unit-test",
234
+ transport: undefined,
235
+ stub: new Proxy({}, { get: () => fail }),
236
+ };
237
+ return new ComponentClient("unit-test", connection);
238
+ }
239
+ export { jsonCodec };
@@ -0,0 +1,44 @@
1
+ import { Duration } from "./time.ts";
2
+ import { type Schema } from "./schema.ts";
3
+ export type ContentType = "application/json" | "text/plain" | "application/octet-stream";
4
+ export declare const JSON_CONTENT: ContentType;
5
+ export declare const TEXT_CONTENT: ContentType;
6
+ export declare const BINARY_CONTENT: ContentType;
7
+ export interface Codec<T> {
8
+ readonly manifest: string;
9
+ readonly contentType: ContentType;
10
+ encode(value: T): Uint8Array;
11
+ decode(bytes: Uint8Array): T;
12
+ }
13
+ /** Where a schema is expected, a codec is accepted too. */
14
+ export type Shape<T> = Schema<T> | Codec<T>;
15
+ export declare function isCodec<T>(shape: Shape<T>): shape is Codec<T>;
16
+ /** The codec for a shape: the codec itself, or the schema's default. */
17
+ export declare function codecFor<T>(shape: Shape<T>): Codec<T>;
18
+ /** The default JSON codec for a schema, under `manifest` (the schema's name when omitted). */
19
+ export declare function jsonCodec<T>(schema: Schema<T>, manifest?: string): Codec<T>;
20
+ /** The `text/plain` codecs for top-level primitives. */
21
+ export declare const textCodecs: Readonly<{
22
+ string: Codec<string>;
23
+ int: Codec<number>;
24
+ short: Codec<number>;
25
+ byte: Codec<number>;
26
+ long: Codec<bigint>;
27
+ double: Codec<number>;
28
+ float: Codec<number>;
29
+ boolean: Codec<boolean>;
30
+ durationMillis: Codec<Duration>;
31
+ }>;
32
+ /** The binary codecs: `done`, `unit`, raw bytes, and a top-level option over another codec. */
33
+ export declare const binaryCodecs: Readonly<{
34
+ done: Codec<Readonly<{
35
+ readonly done: true;
36
+ }>>;
37
+ unit: Codec<undefined>;
38
+ bytes: Codec<Uint8Array<ArrayBufferLike>>;
39
+ option<T>(inner: Codec<T>): Codec<T | null>;
40
+ }>;
41
+ /** The default codec for a schema: text for a top-level scalar, binary for done/unit/bytes/option, JSON otherwise. */
42
+ export declare function defaultCodecFor<T>(schema: Schema<T>): Codec<T>;
43
+ /** The primitive codec a manifest names, including `option[<inner>]`, or `undefined` for a domain manifest. */
44
+ export declare function codecForManifest(manifest: string): Codec<unknown> | undefined;
package/dist/codec.js ADDED
@@ -0,0 +1,215 @@
1
+ // Codecs: a manifest, a content type, and the bytes for a value (protocol/ENCODING.md). The default
2
+ // codec for a schema is derived from it — a record or sum type is JSON under the schema's name; a
3
+ // top-level scalar is `text/plain` under its primitive manifest; `Done`, `unit`, bytes and a top-level
4
+ // option are the binary payloads. A developer may hand over any object satisfying `Codec<T>` where a
5
+ // schema is expected; then portability is their contract.
6
+ import { Duration } from "./time.js";
7
+ import { done, defaultManifest, describe, resolve } from "./schema.js";
8
+ import { readJson, renderDouble, writeJson, DecodingError } from "./json.js";
9
+ export const JSON_CONTENT = "application/json";
10
+ export const TEXT_CONTENT = "text/plain";
11
+ export const BINARY_CONTENT = "application/octet-stream";
12
+ export function isCodec(shape) {
13
+ return typeof shape.encode === "function" && typeof shape.decode === "function";
14
+ }
15
+ /** The codec for a shape: the codec itself, or the schema's default. */
16
+ export function codecFor(shape) {
17
+ return isCodec(shape) ? shape : defaultCodecFor(shape);
18
+ }
19
+ const utf8 = new TextEncoder();
20
+ const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
21
+ function text(bytes) {
22
+ return utf8Decoder.decode(bytes);
23
+ }
24
+ function frozen(codec) {
25
+ return Object.freeze(codec);
26
+ }
27
+ /** The default JSON codec for a schema, under `manifest` (the schema's name when omitted). */
28
+ export function jsonCodec(schema, manifest = defaultManifest(schema)) {
29
+ if (typeof manifest !== "string" || manifest.trim() === "")
30
+ throw new TypeError(`jsonCodec(${describe(schema)}): a manifest is required`);
31
+ return frozen({
32
+ manifest,
33
+ contentType: JSON_CONTENT,
34
+ encode: (value) => utf8.encode(writeJson(schema, value)),
35
+ decode: (bytes) => readJson(schema, text(bytes)),
36
+ });
37
+ }
38
+ function textCodec(manifest, parse, render) {
39
+ return frozen({
40
+ manifest,
41
+ contentType: TEXT_CONTENT,
42
+ encode: (value) => utf8.encode(render(value)),
43
+ decode: (bytes) => parse(text(bytes)),
44
+ });
45
+ }
46
+ function parseInteger(manifest, min, max) {
47
+ return (s) => {
48
+ if (!/^-?\d+$/.test(s.trim()))
49
+ throw new DecodingError(`not a whole number for ${manifest}: ${JSON.stringify(s)}`, "");
50
+ const n = Number(s);
51
+ if (!Number.isSafeInteger(n) || n < min || n > max)
52
+ throw new DecodingError(`${s} does not fit ${manifest}`, "");
53
+ return n;
54
+ };
55
+ }
56
+ function renderInteger(manifest) {
57
+ return (v) => {
58
+ if (!Number.isSafeInteger(v))
59
+ throw new TypeError(`${manifest}: expected a whole number, got ${String(v)}`);
60
+ return String(v);
61
+ };
62
+ }
63
+ function parseDouble(s) {
64
+ const t = s.trim();
65
+ if (t === "NaN")
66
+ return NaN;
67
+ if (t === "Infinity")
68
+ return Infinity;
69
+ if (t === "-Infinity")
70
+ return -Infinity;
71
+ const n = Number(t);
72
+ if (t === "" || Number.isNaN(n))
73
+ throw new DecodingError(`not a number: ${JSON.stringify(s)}`, "");
74
+ return n;
75
+ }
76
+ function renderDoubleText(d) {
77
+ if (Number.isNaN(d))
78
+ return "NaN";
79
+ if (d === Infinity)
80
+ return "Infinity";
81
+ if (d === -Infinity)
82
+ return "-Infinity";
83
+ return renderDouble(d);
84
+ }
85
+ /** The `text/plain` codecs for top-level primitives. */
86
+ export const textCodecs = Object.freeze({
87
+ string: textCodec("string", (s) => s, (v) => v),
88
+ int: textCodec("int", parseInteger("int", -2147483648, 2147483647), renderInteger("int")),
89
+ short: textCodec("short", parseInteger("short", -32768, 32767), renderInteger("short")),
90
+ byte: textCodec("byte", parseInteger("byte", -128, 127), renderInteger("byte")),
91
+ long: textCodec("long", (s) => {
92
+ if (!/^-?\d+$/.test(s.trim()))
93
+ throw new DecodingError(`not a whole number for long: ${JSON.stringify(s)}`, "");
94
+ return BigInt(s.trim());
95
+ }, (v) => {
96
+ if (typeof v === "bigint")
97
+ return v.toString();
98
+ if (typeof v === "number" && Number.isSafeInteger(v))
99
+ return String(v);
100
+ throw new TypeError(`long: expected a bigint, got ${String(v)}`);
101
+ }),
102
+ double: textCodec("double", parseDouble, renderDoubleText),
103
+ float: textCodec("float", parseDouble, renderDoubleText),
104
+ boolean: textCodec("boolean", (s) => {
105
+ const t = s.trim();
106
+ if (t === "true")
107
+ return true;
108
+ if (t === "false")
109
+ return false;
110
+ throw new DecodingError(`not a boolean: ${JSON.stringify(s)}`, "");
111
+ }, (v) => (v ? "true" : "false")),
112
+ durationMillis: textCodec("duration-millis", (s) => {
113
+ if (!/^-?\d+$/.test(s.trim()))
114
+ throw new DecodingError(`not a millisecond count: ${JSON.stringify(s)}`, "");
115
+ return Duration.ofMillis(Number(s.trim()));
116
+ }, (v) => String(v.toMillis())),
117
+ });
118
+ const EMPTY = new Uint8Array(0);
119
+ /** The binary codecs: `done`, `unit`, raw bytes, and a top-level option over another codec. */
120
+ export const binaryCodecs = Object.freeze({
121
+ done: frozen({
122
+ manifest: "done",
123
+ contentType: BINARY_CONTENT,
124
+ encode: () => EMPTY,
125
+ decode: () => done,
126
+ }),
127
+ unit: frozen({
128
+ manifest: "unit",
129
+ contentType: BINARY_CONTENT,
130
+ encode: () => EMPTY,
131
+ decode: () => undefined,
132
+ }),
133
+ bytes: frozen({
134
+ manifest: "bytes",
135
+ contentType: BINARY_CONTENT,
136
+ encode: (v) => v,
137
+ decode: (b) => b,
138
+ }),
139
+ option(inner) {
140
+ return frozen({
141
+ manifest: `option[${inner.manifest}]`,
142
+ contentType: BINARY_CONTENT,
143
+ encode: (value) => {
144
+ if (value === null || value === undefined)
145
+ return EMPTY;
146
+ const encoded = inner.encode(value);
147
+ const out = new Uint8Array(encoded.length + 1);
148
+ out[0] = 1;
149
+ out.set(encoded, 1);
150
+ return out;
151
+ },
152
+ decode: (bytes) => {
153
+ if (bytes.length === 0)
154
+ return null;
155
+ if (bytes[0] !== 1)
156
+ throw new DecodingError(`option[${inner.manifest}]: expected a 0x01 marker, got ${bytes[0]}`, "");
157
+ return inner.decode(bytes.subarray(1));
158
+ },
159
+ });
160
+ },
161
+ });
162
+ /** The default codec for a schema: text for a top-level scalar, binary for done/unit/bytes/option, JSON otherwise. */
163
+ export function defaultCodecFor(schema) {
164
+ const r = resolve(schema);
165
+ switch (r.kind) {
166
+ case "string":
167
+ return textCodecs.string;
168
+ case "int":
169
+ return textCodecs.int;
170
+ case "long":
171
+ return textCodecs.long;
172
+ case "double":
173
+ return textCodecs.double;
174
+ case "boolean":
175
+ return textCodecs.boolean;
176
+ case "duration":
177
+ return textCodecs.durationMillis;
178
+ case "bytes":
179
+ return binaryCodecs.bytes;
180
+ case "done":
181
+ return binaryCodecs.done;
182
+ case "unit":
183
+ return binaryCodecs.unit;
184
+ case "option":
185
+ return binaryCodecs.option(defaultCodecFor(r.inner));
186
+ default:
187
+ return jsonCodec(schema);
188
+ }
189
+ }
190
+ const primitives = Object.freeze({
191
+ string: textCodecs.string,
192
+ int: textCodecs.int,
193
+ long: textCodecs.long,
194
+ short: textCodecs.short,
195
+ byte: textCodecs.byte,
196
+ double: textCodecs.double,
197
+ float: textCodecs.float,
198
+ boolean: textCodecs.boolean,
199
+ "duration-millis": textCodecs.durationMillis,
200
+ done: binaryCodecs.done,
201
+ unit: binaryCodecs.unit,
202
+ bytes: binaryCodecs.bytes,
203
+ });
204
+ /** The primitive codec a manifest names, including `option[<inner>]`, or `undefined` for a domain manifest. */
205
+ export function codecForManifest(manifest) {
206
+ const direct = primitives[manifest];
207
+ if (direct)
208
+ return direct;
209
+ const m = /^option\[(.+)\]$/.exec(manifest);
210
+ if (m) {
211
+ const inner = codecForManifest(m[1]);
212
+ return inner ? binaryCodecs.option(inner) : undefined;
213
+ }
214
+ return undefined;
215
+ }
@@ -0,0 +1,31 @@
1
+ import type { Shape } from "./codec.ts";
2
+ import type { Metadata } from "./effects/common.ts";
3
+ import type { ComponentClient, ComponentRef } from "./client.ts";
4
+ import { ConsumerEffects, type ConsumerEffect } from "./effects/stateless.ts";
5
+ type MaybePromise<T> = T | Promise<T>;
6
+ export declare abstract class Consumer<M, Out = never> {
7
+ #private;
8
+ readonly effects: ConsumerEffects<Out>;
9
+ /** The message's metadata: `ce-subject` is the source instance's id, `ankka.sequence` its sequence number. */
10
+ get metadata(): Metadata;
11
+ get subject(): string;
12
+ get client(): ComponentClient;
13
+ /** @internal */
14
+ get _kind(): "consumer";
15
+ abstract onMessage(message: M): MaybePromise<ConsumerEffect<Out>>;
16
+ /** The source was deleted. Ignored unless this says otherwise. */
17
+ onDelete(): MaybePromise<ConsumerEffect<Out>>;
18
+ /** @internal */
19
+ _bind(metadata: Metadata, client: ComponentClient): void;
20
+ }
21
+ export interface ConsumerClass<M = unknown, Out = unknown, C extends Consumer<M, Out> = Consumer<M, Out>> {
22
+ new (): C;
23
+ readonly componentId: string;
24
+ readonly source?: ComponentRef;
25
+ readonly topic?: string;
26
+ readonly message: Shape<M>;
27
+ /** The shape of what `produce` sends onward; required with `producesTo`. */
28
+ readonly out?: Shape<Out>;
29
+ readonly producesTo?: string;
30
+ }
31
+ export {};
@@ -0,0 +1,32 @@
1
+ // A consumer: reacts to a source's changes, optionally producing onward to a topic.
2
+ import { ConsumerEffects } from "./effects/stateless.js";
3
+ export class Consumer {
4
+ effects = new ConsumerEffects();
5
+ #metadata = {};
6
+ #client;
7
+ /** The message's metadata: `ce-subject` is the source instance's id, `ankka.sequence` its sequence number. */
8
+ get metadata() {
9
+ return this.#metadata;
10
+ }
11
+ get subject() {
12
+ return this.#metadata["ce-subject"] ?? "";
13
+ }
14
+ get client() {
15
+ if (!this.#client)
16
+ throw new Error("client is only available inside onMessage or onDelete");
17
+ return this.#client;
18
+ }
19
+ /** @internal */
20
+ get _kind() {
21
+ return "consumer";
22
+ }
23
+ /** The source was deleted. Ignored unless this says otherwise. */
24
+ onDelete() {
25
+ return this.effects.ignore();
26
+ }
27
+ /** @internal */
28
+ _bind(metadata, client) {
29
+ this.#metadata = metadata;
30
+ this.#client = client;
31
+ }
32
+ }