ankka 0.9.1 → 0.10.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.
- package/dist/_proto/ankka/protocol/v1/consumer_pb.d.ts +52 -1
- package/dist/_proto/ankka/protocol/v1/consumer_pb.js +11 -1
- package/dist/consumer.d.ts +16 -0
- package/dist/consumer.js +30 -0
- package/dist/effects/stateless.d.ts +21 -0
- package/dist/effects/stateless.js +19 -0
- package/dist/graph.d.ts +157 -0
- package/dist/graph.js +426 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/server/stateless.js +66 -16
- package/dist/service.d.ts +5 -1
- package/dist/service.js +28 -1
- package/dist/spec.d.ts +1 -1
- package/dist/spec.js +1 -1
- package/dist/testkit/index.d.ts +1 -1
- package/dist/testkit/index.js +1 -1
- package/dist/testkit/kinds.d.ts +32 -3
- package/dist/testkit/kinds.js +68 -8
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -20,12 +20,14 @@ export type ConsumerRequest = Message<"ankka.protocol.v1.ConsumerRequest"> & {
|
|
|
20
20
|
*/
|
|
21
21
|
message?: Payload | undefined;
|
|
22
22
|
/**
|
|
23
|
-
* ce-subject: the source's id; ankka.sequence
|
|
23
|
+
* ce-subject: the source's id; ankka.sequence;
|
|
24
24
|
*
|
|
25
25
|
* @generated from field: ankka.protocol.v1.Metadata metadata = 3;
|
|
26
26
|
*/
|
|
27
27
|
metadata?: Metadata | undefined;
|
|
28
28
|
/**
|
|
29
|
+
* ankka.protocol: what the runtime speaks (1.3)
|
|
30
|
+
*
|
|
29
31
|
* the source was deleted
|
|
30
32
|
*
|
|
31
33
|
* @generated from field: bool deleted = 4;
|
|
@@ -62,6 +64,14 @@ export type ConsumerEffect = Message<"ankka.protocol.v1.ConsumerEffect"> & {
|
|
|
62
64
|
*/
|
|
63
65
|
value: Empty;
|
|
64
66
|
case: "ignore";
|
|
67
|
+
} | {
|
|
68
|
+
/**
|
|
69
|
+
* 1.3: several messages for one change
|
|
70
|
+
*
|
|
71
|
+
* @generated from field: ankka.protocol.v1.ConsumerEffect.ProduceAll produce_all = 4;
|
|
72
|
+
*/
|
|
73
|
+
value: ConsumerEffect_ProduceAll;
|
|
74
|
+
case: "produceAll";
|
|
65
75
|
} | {
|
|
66
76
|
case: undefined;
|
|
67
77
|
value?: undefined;
|
|
@@ -90,6 +100,47 @@ export type ConsumerEffect_Produce = Message<"ankka.protocol.v1.ConsumerEffect.P
|
|
|
90
100
|
* Use `create(ConsumerEffect_ProduceSchema)` to create a new message.
|
|
91
101
|
*/
|
|
92
102
|
export declare const ConsumerEffect_ProduceSchema: GenMessage<ConsumerEffect_Produce>;
|
|
103
|
+
/**
|
|
104
|
+
* Published in order; the change is handled when the broker has accepted all of them. None at
|
|
105
|
+
* all is `done`. Send it only to a runtime whose request carried `ankka.protocol` >= 1.3.
|
|
106
|
+
*
|
|
107
|
+
* @generated from message ankka.protocol.v1.ConsumerEffect.ProduceAll
|
|
108
|
+
*/
|
|
109
|
+
export type ConsumerEffect_ProduceAll = Message<"ankka.protocol.v1.ConsumerEffect.ProduceAll"> & {
|
|
110
|
+
/**
|
|
111
|
+
* @generated from field: repeated ankka.protocol.v1.ConsumerEffect.Message messages = 1;
|
|
112
|
+
*/
|
|
113
|
+
messages: ConsumerEffect_Message[];
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Describes the message ankka.protocol.v1.ConsumerEffect.ProduceAll.
|
|
117
|
+
* Use `create(ConsumerEffect_ProduceAllSchema)` to create a new message.
|
|
118
|
+
*/
|
|
119
|
+
export declare const ConsumerEffect_ProduceAllSchema: GenMessage<ConsumerEffect_ProduceAll>;
|
|
120
|
+
/**
|
|
121
|
+
* @generated from message ankka.protocol.v1.ConsumerEffect.Message
|
|
122
|
+
*/
|
|
123
|
+
export type ConsumerEffect_Message = Message<"ankka.protocol.v1.ConsumerEffect.Message"> & {
|
|
124
|
+
/**
|
|
125
|
+
* @generated from field: ankka.protocol.v1.Payload payload = 1;
|
|
126
|
+
*/
|
|
127
|
+
payload?: Payload | undefined;
|
|
128
|
+
/**
|
|
129
|
+
* @generated from field: ankka.protocol.v1.Metadata metadata = 2;
|
|
130
|
+
*/
|
|
131
|
+
metadata?: Metadata | undefined;
|
|
132
|
+
/**
|
|
133
|
+
* the record key; absent: the message's subject
|
|
134
|
+
*
|
|
135
|
+
* @generated from field: optional string key = 3;
|
|
136
|
+
*/
|
|
137
|
+
key?: string | undefined;
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* Describes the message ankka.protocol.v1.ConsumerEffect.Message.
|
|
141
|
+
* Use `create(ConsumerEffect_MessageSchema)` to create a new message.
|
|
142
|
+
*/
|
|
143
|
+
export declare const ConsumerEffect_MessageSchema: GenMessage<ConsumerEffect_Message>;
|
|
93
144
|
/**
|
|
94
145
|
* @generated from service ankka.protocol.v1.Consumer
|
|
95
146
|
*/
|
|
@@ -6,7 +6,7 @@ import { file_ankka_protocol_v1_payload } from "./payload_pb.js";
|
|
|
6
6
|
/**
|
|
7
7
|
* Describes the file ankka/protocol/v1/consumer.proto.
|
|
8
8
|
*/
|
|
9
|
-
export const file_ankka_protocol_v1_consumer = /*@__PURE__*/ fileDesc("
|
|
9
|
+
export const file_ankka_protocol_v1_consumer = /*@__PURE__*/ fileDesc("CiBhbmtrYS9wcm90b2NvbC92MS9jb25zdW1lci5wcm90bxIRYW5ra2EucHJvdG9jb2wudjEilAEKD0NvbnN1bWVyUmVxdWVzdBIUCgxjb21wb25lbnRfaWQYASABKAkSKwoHbWVzc2FnZRgCIAEoCzIaLmFua2thLnByb3RvY29sLnYxLlBheWxvYWQSLQoIbWV0YWRhdGEYAyABKAsyGy5hbmtrYS5wcm90b2NvbC52MS5NZXRhZGF0YRIPCgdkZWxldGVkGAQgASgIIqYECg5Db25zdW1lckVmZmVjdBI8Cgdwcm9kdWNlGAEgASgLMikuYW5ra2EucHJvdG9jb2wudjEuQ29uc3VtZXJFZmZlY3QuUHJvZHVjZUgAEigKBGRvbmUYAiABKAsyGC5hbmtrYS5wcm90b2NvbC52MS5FbXB0eUgAEioKBmlnbm9yZRgDIAEoCzIYLmFua2thLnByb3RvY29sLnYxLkVtcHR5SAASQwoLcHJvZHVjZV9hbGwYBCABKAsyLC5hbmtrYS5wcm90b2NvbC52MS5Db25zdW1lckVmZmVjdC5Qcm9kdWNlQWxsSAAaZQoHUHJvZHVjZRIrCgdwYXlsb2FkGAEgASgLMhouYW5ra2EucHJvdG9jb2wudjEuUGF5bG9hZBItCghtZXRhZGF0YRgCIAEoCzIbLmFua2thLnByb3RvY29sLnYxLk1ldGFkYXRhGkkKClByb2R1Y2VBbGwSOwoIbWVzc2FnZXMYASADKAsyKS5hbmtrYS5wcm90b2NvbC52MS5Db25zdW1lckVmZmVjdC5NZXNzYWdlGn8KB01lc3NhZ2USKwoHcGF5bG9hZBgBIAEoCzIaLmFua2thLnByb3RvY29sLnYxLlBheWxvYWQSLQoIbWV0YWRhdGEYAiABKAsyGy5hbmtrYS5wcm90b2NvbC52MS5NZXRhZGF0YRIQCgNrZXkYAyABKAlIAIgBAUIGCgRfa2V5QggKBmVmZmVjdDJbCghDb25zdW1lchJPCgZIYW5kbGUSIi5hbmtrYS5wcm90b2NvbC52MS5Db25zdW1lclJlcXVlc3QaIS5hbmtrYS5wcm90b2NvbC52MS5Db25zdW1lckVmZmVjdGIGcHJvdG8z", [file_ankka_protocol_v1_payload]);
|
|
10
10
|
/**
|
|
11
11
|
* Describes the message ankka.protocol.v1.ConsumerRequest.
|
|
12
12
|
* Use `create(ConsumerRequestSchema)` to create a new message.
|
|
@@ -22,6 +22,16 @@ export const ConsumerEffectSchema = /*@__PURE__*/ messageDesc(file_ankka_protoco
|
|
|
22
22
|
* Use `create(ConsumerEffect_ProduceSchema)` to create a new message.
|
|
23
23
|
*/
|
|
24
24
|
export const ConsumerEffect_ProduceSchema = /*@__PURE__*/ messageDesc(file_ankka_protocol_v1_consumer, 1, 0);
|
|
25
|
+
/**
|
|
26
|
+
* Describes the message ankka.protocol.v1.ConsumerEffect.ProduceAll.
|
|
27
|
+
* Use `create(ConsumerEffect_ProduceAllSchema)` to create a new message.
|
|
28
|
+
*/
|
|
29
|
+
export const ConsumerEffect_ProduceAllSchema = /*@__PURE__*/ messageDesc(file_ankka_protocol_v1_consumer, 1, 1);
|
|
30
|
+
/**
|
|
31
|
+
* Describes the message ankka.protocol.v1.ConsumerEffect.Message.
|
|
32
|
+
* Use `create(ConsumerEffect_MessageSchema)` to create a new message.
|
|
33
|
+
*/
|
|
34
|
+
export const ConsumerEffect_MessageSchema = /*@__PURE__*/ messageDesc(file_ankka_protocol_v1_consumer, 1, 2);
|
|
25
35
|
/**
|
|
26
36
|
* @generated from service ankka.protocol.v1.Consumer
|
|
27
37
|
*/
|
package/dist/consumer.d.ts
CHANGED
|
@@ -3,12 +3,28 @@ import type { Metadata } from "./effects/common.ts";
|
|
|
3
3
|
import type { ComponentClient, ComponentRef } from "./client.ts";
|
|
4
4
|
import { ConsumerEffects, type ConsumerEffect } from "./effects/stateless.ts";
|
|
5
5
|
type MaybePromise<T> = T | Promise<T>;
|
|
6
|
+
/** The metadata entry a runtime sets on a consumer's request to say what it speaks: its protocol version. */
|
|
7
|
+
export declare const PROTOCOL_KEY = "ankka.protocol";
|
|
8
|
+
/** Thrown when a consumer answers with several messages, or a record key, to a runtime that would drop them. */
|
|
9
|
+
export declare class ProtocolVersionError extends Error {
|
|
10
|
+
constructor(declared: string | undefined);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Refuses to answer with several messages unless the request says the runtime accepts them. A runtime
|
|
14
|
+
* before protocol 1.3 reads the reply as no effect at all and records the change as handled with
|
|
15
|
+
* nothing published, so the request is failed instead: the change is delivered again, visibly.
|
|
16
|
+
*/
|
|
17
|
+
export declare function requireSeveralMessages(metadata: Metadata): void;
|
|
18
|
+
/** The change's sequence number from its metadata: an event's, a key value state's revision; absent when there is none. */
|
|
19
|
+
export declare function sequenceNumberOf(metadata: Metadata): bigint | undefined;
|
|
6
20
|
export declare abstract class Consumer<M, Out = never> {
|
|
7
21
|
#private;
|
|
8
22
|
readonly effects: ConsumerEffects<Out>;
|
|
9
23
|
/** The message's metadata: `ce-subject` is the source instance's id, `ankka.sequence` its sequence number. */
|
|
10
24
|
get metadata(): Metadata;
|
|
11
25
|
get subject(): string;
|
|
26
|
+
/** The change's sequence number: an event's, or a key value state's revision. `0n` for a topic's message. */
|
|
27
|
+
get sequenceNumber(): bigint | undefined;
|
|
12
28
|
get client(): ComponentClient;
|
|
13
29
|
/** @internal */
|
|
14
30
|
get _kind(): "consumer";
|
package/dist/consumer.js
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
// A consumer: reacts to a source's changes, optionally producing onward to a topic.
|
|
2
2
|
import { ConsumerEffects } from "./effects/stateless.js";
|
|
3
|
+
/** The metadata entry a runtime sets on a consumer's request to say what it speaks: its protocol version. */
|
|
4
|
+
export const PROTOCOL_KEY = "ankka.protocol";
|
|
5
|
+
/** Thrown when a consumer answers with several messages, or a record key, to a runtime that would drop them. */
|
|
6
|
+
export class ProtocolVersionError extends Error {
|
|
7
|
+
constructor(declared) {
|
|
8
|
+
super(`this runtime speaks protocol ${declared ?? "1.2 or earlier"}; several messages or a record key need 1.3`);
|
|
9
|
+
this.name = "ProtocolVersionError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Refuses to answer with several messages unless the request says the runtime accepts them. A runtime
|
|
14
|
+
* before protocol 1.3 reads the reply as no effect at all and records the change as handled with
|
|
15
|
+
* nothing published, so the request is failed instead: the change is delivered again, visibly.
|
|
16
|
+
*/
|
|
17
|
+
export function requireSeveralMessages(metadata) {
|
|
18
|
+
const declared = metadata[PROTOCOL_KEY];
|
|
19
|
+
const match = declared === undefined ? null : /^(\d+)\.(\d+)$/.exec(declared.trim());
|
|
20
|
+
const accepted = match !== null && (Number(match[1]) > 1 || (Number(match[1]) === 1 && Number(match[2]) >= 3));
|
|
21
|
+
if (!accepted)
|
|
22
|
+
throw new ProtocolVersionError(declared);
|
|
23
|
+
}
|
|
24
|
+
/** The change's sequence number from its metadata: an event's, a key value state's revision; absent when there is none. */
|
|
25
|
+
export function sequenceNumberOf(metadata) {
|
|
26
|
+
const raw = metadata["ankka.sequence"];
|
|
27
|
+
return raw !== undefined && /^-?\d+$/.test(raw) ? BigInt(raw) : undefined;
|
|
28
|
+
}
|
|
3
29
|
export class Consumer {
|
|
4
30
|
effects = new ConsumerEffects();
|
|
5
31
|
#metadata = {};
|
|
@@ -11,6 +37,10 @@ export class Consumer {
|
|
|
11
37
|
get subject() {
|
|
12
38
|
return this.#metadata["ce-subject"] ?? "";
|
|
13
39
|
}
|
|
40
|
+
/** The change's sequence number: an event's, or a key value state's revision. `0n` for a topic's message. */
|
|
41
|
+
get sequenceNumber() {
|
|
42
|
+
return sequenceNumberOf(this.#metadata);
|
|
43
|
+
}
|
|
14
44
|
get client() {
|
|
15
45
|
if (!this.#client)
|
|
16
46
|
throw new Error("client is only available inside onMessage or onDelete");
|
|
@@ -12,10 +12,25 @@ export declare class ViewEffects<Row> {
|
|
|
12
12
|
deleteRow(): ViewEffect<Row>;
|
|
13
13
|
ignore(): ViewEffect<Row>;
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* One message of several: what to publish, optionally the record key it is published under, and its
|
|
17
|
+
* headers. A message with no key is keyed by its subject — the source instance's id unless the
|
|
18
|
+
* metadata sets `ce-subject` — which keeps everything about one instance on one partition, in order.
|
|
19
|
+
* A key is for a message about something else: a line item, an element of a graph. Naming one does
|
|
20
|
+
* not change the subject.
|
|
21
|
+
*/
|
|
22
|
+
export interface OutgoingMessage<Out> {
|
|
23
|
+
readonly payload: Out;
|
|
24
|
+
readonly key?: string;
|
|
25
|
+
readonly metadata?: Metadata;
|
|
26
|
+
}
|
|
15
27
|
export type ConsumerEffect<Out> = ({
|
|
16
28
|
readonly kind: "produce";
|
|
17
29
|
readonly payload: Out;
|
|
18
30
|
readonly metadata: Metadata;
|
|
31
|
+
} & EffectLike<never>) | ({
|
|
32
|
+
readonly kind: "produceAll";
|
|
33
|
+
readonly messages: readonly OutgoingMessage<Out>[];
|
|
19
34
|
} & EffectLike<never>) | ({
|
|
20
35
|
readonly kind: "done";
|
|
21
36
|
} & EffectLike<never>) | ({
|
|
@@ -24,6 +39,12 @@ export type ConsumerEffect<Out> = ({
|
|
|
24
39
|
export declare class ConsumerEffects<Out> {
|
|
25
40
|
/** Produce onward to the topic the consumer declares in `producesTo`. */
|
|
26
41
|
produce(payload: Out, metadata?: Metadata): ConsumerEffect<Out>;
|
|
42
|
+
/**
|
|
43
|
+
* Produce several messages for this change, in the order given. The change is handled when the
|
|
44
|
+
* broker has accepted every one; if one is refused the change comes again and all are published
|
|
45
|
+
* again. An empty list publishes nothing and is handled at once.
|
|
46
|
+
*/
|
|
47
|
+
produceAll(messages: readonly OutgoingMessage<Out>[]): ConsumerEffect<Out>;
|
|
27
48
|
done(): ConsumerEffect<Out>;
|
|
28
49
|
ignore(): ConsumerEffect<Out>;
|
|
29
50
|
}
|
|
@@ -17,6 +17,25 @@ export class ConsumerEffects {
|
|
|
17
17
|
produce(payload, metadata = {}) {
|
|
18
18
|
return Object.freeze({ kind: "produce", payload, metadata });
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Produce several messages for this change, in the order given. The change is handled when the
|
|
22
|
+
* broker has accepted every one; if one is refused the change comes again and all are published
|
|
23
|
+
* again. An empty list publishes nothing and is handled at once.
|
|
24
|
+
*/
|
|
25
|
+
produceAll(messages) {
|
|
26
|
+
if (!Array.isArray(messages))
|
|
27
|
+
throw new TypeError("produceAll takes a list of messages");
|
|
28
|
+
const checked = messages.map((m, i) => {
|
|
29
|
+
if (typeof m !== "object" || m === null || !("payload" in m))
|
|
30
|
+
throw new TypeError(`produceAll: message ${i} is not a message: { payload, key?, metadata? }`);
|
|
31
|
+
if (m.key !== undefined && typeof m.key !== "string")
|
|
32
|
+
throw new TypeError(`produceAll: message ${i} has a record key that is not a string`);
|
|
33
|
+
if (m.key === "")
|
|
34
|
+
throw new TypeError(`produceAll: message ${i} has an empty record key; leave the key out to key the message by its subject`);
|
|
35
|
+
return Object.freeze({ ...m });
|
|
36
|
+
});
|
|
37
|
+
return Object.freeze({ kind: "produceAll", messages: Object.freeze(checked) });
|
|
38
|
+
}
|
|
20
39
|
done() {
|
|
21
40
|
return Object.freeze({ kind: "done" });
|
|
22
41
|
}
|
package/dist/graph.d.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { type Codec, type Shape } from "./codec.ts";
|
|
2
|
+
import type { EffectLike, Metadata } from "./effects/common.ts";
|
|
3
|
+
import type { ComponentClient, ComponentRef } from "./client.ts";
|
|
4
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
5
|
+
/** The contract's name: the manifest and the `ce-type` of every delta. */
|
|
6
|
+
export declare const GRAPH_DELTA_SCHEMA = "ankka.graph-delta.v1";
|
|
7
|
+
/** The record key of every delta for the node `id`. */
|
|
8
|
+
export declare function nodeKey(id: string): string;
|
|
9
|
+
/** The record key of every delta for the edge `id`. Nodes and edges are separate id spaces. */
|
|
10
|
+
export declare function edgeKey(id: string): string;
|
|
11
|
+
/** A property's scalar: an integral `number` must be a safe integer; a larger integer is a `bigint`. */
|
|
12
|
+
export type Scalar = string | boolean | number | bigint;
|
|
13
|
+
/** A scalar, or a non-empty list of scalars of one kind. */
|
|
14
|
+
export type PropertyValue = Scalar | readonly Scalar[];
|
|
15
|
+
export type Properties = Readonly<Record<string, PropertyValue>>;
|
|
16
|
+
/** A version: a whole number of at least 1, as a safe-integer `number` or a `bigint` within 64 bits. */
|
|
17
|
+
export type Version = number | bigint;
|
|
18
|
+
/** An element as the author describes it. Without a `version` it takes its change's sequence number. */
|
|
19
|
+
export type Element = {
|
|
20
|
+
readonly kind: "node";
|
|
21
|
+
readonly id: string;
|
|
22
|
+
readonly version?: Version;
|
|
23
|
+
readonly labels: readonly string[];
|
|
24
|
+
readonly properties: Properties;
|
|
25
|
+
} | {
|
|
26
|
+
readonly kind: "edge";
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly version?: Version;
|
|
29
|
+
readonly type: string;
|
|
30
|
+
readonly from: string;
|
|
31
|
+
readonly to: string;
|
|
32
|
+
readonly properties: Properties;
|
|
33
|
+
} | {
|
|
34
|
+
readonly kind: "tombstone";
|
|
35
|
+
readonly element: "node";
|
|
36
|
+
readonly id: string;
|
|
37
|
+
readonly version?: Version;
|
|
38
|
+
} | {
|
|
39
|
+
readonly kind: "tombstone";
|
|
40
|
+
readonly element: "edge";
|
|
41
|
+
readonly id: string;
|
|
42
|
+
readonly version?: Version;
|
|
43
|
+
readonly type: string;
|
|
44
|
+
readonly from: string;
|
|
45
|
+
readonly to: string;
|
|
46
|
+
};
|
|
47
|
+
/** An element at a version: what is written and read. */
|
|
48
|
+
export type Delta = Element & {
|
|
49
|
+
readonly version: Version;
|
|
50
|
+
};
|
|
51
|
+
/** Why an element or a result was refused; the names the shared fixture `refused.json` uses. */
|
|
52
|
+
export type GraphFault = "id" | "endpoints" | "identifier" | "reserved" | "property-value" | "integer-range" | "version" | "duplicate" | "no-sequence";
|
|
53
|
+
/** Thrown where an element is built, or when a result is returned, for something the merge sink would refuse. */
|
|
54
|
+
export declare class GraphError extends Error {
|
|
55
|
+
readonly why: GraphFault;
|
|
56
|
+
constructor(why: GraphFault, message: string);
|
|
57
|
+
}
|
|
58
|
+
/** The element key: `node:<id>` or `edge:<id>`; a tombstone has the key of the element it marks. */
|
|
59
|
+
export declare function elementKey(element: Element): string;
|
|
60
|
+
/** `this.graph` in a graph consumer: builds elements, refusing where they are built what the sink would refuse. */
|
|
61
|
+
export declare class Graph {
|
|
62
|
+
node(id: string, options?: {
|
|
63
|
+
readonly labels?: readonly string[];
|
|
64
|
+
readonly properties?: Properties;
|
|
65
|
+
readonly version?: Version;
|
|
66
|
+
}): Element;
|
|
67
|
+
edge(id: string, options: {
|
|
68
|
+
readonly type: string;
|
|
69
|
+
readonly from: string;
|
|
70
|
+
readonly to: string;
|
|
71
|
+
readonly properties?: Properties;
|
|
72
|
+
readonly version?: Version;
|
|
73
|
+
}): Element;
|
|
74
|
+
/** Marks a node deleted. The node stays in the graph, marked, so a straggling older delta cannot bring it back. */
|
|
75
|
+
tombstoneNode(id: string, options?: {
|
|
76
|
+
readonly version?: Version;
|
|
77
|
+
}): Element;
|
|
78
|
+
/** Marks an edge deleted; it names the edge's type and endpoints so the edge is found without a scan. */
|
|
79
|
+
tombstoneEdge(id: string, options: {
|
|
80
|
+
readonly type: string;
|
|
81
|
+
readonly from: string;
|
|
82
|
+
readonly to: string;
|
|
83
|
+
readonly version?: Version;
|
|
84
|
+
}): Element;
|
|
85
|
+
}
|
|
86
|
+
export type GraphEffect = ({
|
|
87
|
+
readonly kind: "publish";
|
|
88
|
+
readonly elements: readonly Element[];
|
|
89
|
+
} & EffectLike<never>) | ({
|
|
90
|
+
readonly kind: "done";
|
|
91
|
+
} & EffectLike<never>) | ({
|
|
92
|
+
readonly kind: "ignore";
|
|
93
|
+
} & EffectLike<never>);
|
|
94
|
+
/** `this.effects` in a graph consumer: there is nothing here that publishes anything but elements. */
|
|
95
|
+
export declare class GraphEffects {
|
|
96
|
+
/** Publish these elements, each as a delta under its own key. An empty list publishes nothing. */
|
|
97
|
+
publish(elements: readonly Element[]): GraphEffect;
|
|
98
|
+
done(): GraphEffect;
|
|
99
|
+
ignore(): GraphEffect;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Reads a delta's value back into an element, for tests and for a consumer of a delta topic. Given a
|
|
103
|
+
* `key` it also holds the contract's rule that the key is the delta's element key. It accepts a
|
|
104
|
+
* version of 0, which the contract allows and the builder does not write. An integer past 2⁵³ comes
|
|
105
|
+
* back as a `bigint`.
|
|
106
|
+
*/
|
|
107
|
+
export declare function readDelta(value: Uint8Array, key?: string): Delta;
|
|
108
|
+
/** The codec of a delta: JSON under the manifest `ankka.graph-delta.v1`. Hand-written, since a delta's properties have no fixed shape. */
|
|
109
|
+
export declare const graphDeltaCodec: Codec<Delta>;
|
|
110
|
+
/** One record a graph consumer's result comes to: the element key, the delta's bytes, and the delta. */
|
|
111
|
+
export interface DeltaRecord {
|
|
112
|
+
readonly key: string;
|
|
113
|
+
readonly value: Uint8Array;
|
|
114
|
+
readonly delta: Delta;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* What a graph consumer's effect publishes for the change `metadata` describes: every element at its
|
|
118
|
+
* stated version, else the change's sequence number, refusing a result that names an element twice or
|
|
119
|
+
* leaves a version to a change that has no sequence number. The server and the test kit both read an
|
|
120
|
+
* effect through this, so they cannot disagree about what a handler published.
|
|
121
|
+
*
|
|
122
|
+
* @internal
|
|
123
|
+
*/
|
|
124
|
+
export declare function deltaRecords(effect: GraphEffect, metadata: Metadata): readonly DeltaRecord[];
|
|
125
|
+
/**
|
|
126
|
+
* A consumer that publishes a graph. Its handlers return elements; each is published to `producesTo`
|
|
127
|
+
* as a delta under its element key. It has no `produce` and no way to set a key, and it is discovered
|
|
128
|
+
* and hosted as an ordinary consumer.
|
|
129
|
+
*/
|
|
130
|
+
export declare abstract class GraphConsumer<M> {
|
|
131
|
+
#private;
|
|
132
|
+
readonly graph: Graph;
|
|
133
|
+
readonly effects: GraphEffects;
|
|
134
|
+
/** The change's metadata: `ce-subject` is the source instance's id, `ankka.sequence` its sequence number. */
|
|
135
|
+
get metadata(): Metadata;
|
|
136
|
+
get subject(): string;
|
|
137
|
+
/** The change's sequence number, which is the version of every element that states none. */
|
|
138
|
+
get sequenceNumber(): bigint | undefined;
|
|
139
|
+
get client(): ComponentClient;
|
|
140
|
+
/** @internal */
|
|
141
|
+
get _kind(): "consumer";
|
|
142
|
+
abstract onMessage(message: M): MaybePromise<GraphEffect>;
|
|
143
|
+
/** The source was deleted. Ignored unless this says otherwise; publish tombstones here. */
|
|
144
|
+
onDelete(): MaybePromise<GraphEffect>;
|
|
145
|
+
/** @internal */
|
|
146
|
+
_bind(metadata: Metadata, client: ComponentClient): void;
|
|
147
|
+
}
|
|
148
|
+
export interface GraphConsumerClass<M = unknown, C extends GraphConsumer<M> = GraphConsumer<M>> {
|
|
149
|
+
new (): C;
|
|
150
|
+
readonly componentId: string;
|
|
151
|
+
readonly source?: ComponentRef;
|
|
152
|
+
readonly topic?: string;
|
|
153
|
+
readonly message: Shape<M>;
|
|
154
|
+
/** The delta topic. It must be compacted to hold the graph; the pipeline that reads it creates it so. */
|
|
155
|
+
readonly producesTo: string;
|
|
156
|
+
}
|
|
157
|
+
export {};
|
package/dist/graph.js
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
// Publishing a graph: a consumer that says which elements a change leaves in which state, and has each
|
|
2
|
+
// published as a graph delta — one element's whole state at a version, or a tombstone marking it
|
|
3
|
+
// deleted — under the contract `ankka.graph-delta.v1`. The record's key is the element's
|
|
4
|
+
// (`node:<id>` or `edge:<id>`), never the author's to set, and its version is the change's sequence
|
|
5
|
+
// number unless one is stated. A graph database kept in step with the topic applies a delta when its
|
|
6
|
+
// version is newer than what it holds, so a change handled twice does no harm.
|
|
7
|
+
//
|
|
8
|
+
// export class CartGraph extends GraphConsumer<ShoppingCartEvent> {
|
|
9
|
+
// static readonly componentId = "cart-graph"
|
|
10
|
+
// static readonly source = ShoppingCartEntity
|
|
11
|
+
// static readonly message = ShoppingCartEntity.events
|
|
12
|
+
// static readonly producesTo = "cart-graph"
|
|
13
|
+
// onMessage(event) {
|
|
14
|
+
// return this.effects.publish([this.graph.node(`cart:${this.subject}`, { labels: ["Cart"] })])
|
|
15
|
+
// }
|
|
16
|
+
// }
|
|
17
|
+
import { JSON_CONTENT } from "./codec.js";
|
|
18
|
+
import { sequenceNumberOf } from "./consumer.js";
|
|
19
|
+
import { renderDouble, reviver } from "./json.js";
|
|
20
|
+
/** The contract's name: the manifest and the `ce-type` of every delta. */
|
|
21
|
+
export const GRAPH_DELTA_SCHEMA = "ankka.graph-delta.v1";
|
|
22
|
+
/** The record key of every delta for the node `id`. */
|
|
23
|
+
export function nodeKey(id) {
|
|
24
|
+
return `node:${id}`;
|
|
25
|
+
}
|
|
26
|
+
/** The record key of every delta for the edge `id`. Nodes and edges are separate id spaces. */
|
|
27
|
+
export function edgeKey(id) {
|
|
28
|
+
return `edge:${id}`;
|
|
29
|
+
}
|
|
30
|
+
/** Thrown where an element is built, or when a result is returned, for something the merge sink would refuse. */
|
|
31
|
+
export class GraphError extends Error {
|
|
32
|
+
why;
|
|
33
|
+
constructor(why, message) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "GraphError";
|
|
36
|
+
this.why = why;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
40
|
+
const RESERVED = new Set(["id", "_version", "_deleted"]);
|
|
41
|
+
const INT64_MIN = -(2n ** 63n);
|
|
42
|
+
const INT64_MAX = 2n ** 63n - 1n;
|
|
43
|
+
function show(value) {
|
|
44
|
+
if (typeof value === "bigint")
|
|
45
|
+
return `${value}n`;
|
|
46
|
+
if (typeof value === "number")
|
|
47
|
+
return String(value);
|
|
48
|
+
try {
|
|
49
|
+
return JSON.stringify(value) ?? String(value);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return String(value);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function spaceOf(element) {
|
|
56
|
+
return element.kind === "edge" || (element.kind === "tombstone" && element.element === "edge") ? "edge" : "node";
|
|
57
|
+
}
|
|
58
|
+
function describe(element) {
|
|
59
|
+
const what = element.kind === "tombstone" ? `tombstone of ${spaceOf(element)}` : element.kind;
|
|
60
|
+
return `${what} ${show(element.id)}`;
|
|
61
|
+
}
|
|
62
|
+
/** The element key: `node:<id>` or `edge:<id>`; a tombstone has the key of the element it marks. */
|
|
63
|
+
export function elementKey(element) {
|
|
64
|
+
return spaceOf(element) === "edge" ? edgeKey(element.id) : nodeKey(element.id);
|
|
65
|
+
}
|
|
66
|
+
/** The kind of one scalar. A float with a whole value is an integer, as the sink reads it. */
|
|
67
|
+
function scalarKind(who, name, value) {
|
|
68
|
+
switch (typeof value) {
|
|
69
|
+
case "string":
|
|
70
|
+
return "string";
|
|
71
|
+
case "boolean":
|
|
72
|
+
return "boolean";
|
|
73
|
+
case "bigint":
|
|
74
|
+
if (value < INT64_MIN || value > INT64_MAX)
|
|
75
|
+
throw new GraphError("integer-range", `${who}: property ${show(name)} is ${value}, which does not fit 64 bits`);
|
|
76
|
+
return "integer";
|
|
77
|
+
case "number":
|
|
78
|
+
if (!Number.isFinite(value))
|
|
79
|
+
throw new GraphError("property-value", `${who}: property ${show(name)} is ${value}, which is not a finite number`);
|
|
80
|
+
if (!Number.isInteger(value))
|
|
81
|
+
return "float";
|
|
82
|
+
if (Number.isSafeInteger(value))
|
|
83
|
+
return "integer";
|
|
84
|
+
if (value < Number(INT64_MIN) || value >= 2 ** 63)
|
|
85
|
+
throw new GraphError("integer-range", `${who}: property ${show(name)} is ${value}, a whole number that does not fit 64 bits`);
|
|
86
|
+
throw new GraphError("integer-range", `${who}: property ${show(name)} is ${value}, a whole number past 2^53 that a number cannot hold exactly; pass it as a bigint`);
|
|
87
|
+
default:
|
|
88
|
+
throw new GraphError("property-value", `${who}: property ${show(name)} is ${show(value)}; a property is a string, a number, a boolean, or a non-empty list of one of those`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function checkProperties(who, properties) {
|
|
92
|
+
if (properties === undefined)
|
|
93
|
+
return Object.freeze({});
|
|
94
|
+
if (typeof properties !== "object" || properties === null || Array.isArray(properties)) {
|
|
95
|
+
throw new GraphError("property-value", `${who}: properties must be an object of property values, not ${show(properties)}`);
|
|
96
|
+
}
|
|
97
|
+
const out = {};
|
|
98
|
+
for (const [name, value] of Object.entries(properties)) {
|
|
99
|
+
if (RESERVED.has(name))
|
|
100
|
+
throw new GraphError("reserved", `${who}: property ${show(name)} is reserved`);
|
|
101
|
+
if (Array.isArray(value)) {
|
|
102
|
+
if (value.length === 0)
|
|
103
|
+
throw new GraphError("property-value", `${who}: property ${show(name)} is an empty list; leave the property out instead`);
|
|
104
|
+
const kinds = new Set(value.map((v) => scalarKind(who, name, v)));
|
|
105
|
+
if (kinds.size > 1)
|
|
106
|
+
throw new GraphError("property-value", `${who}: property ${show(name)} is a list of more than one kind (${[...kinds].join(", ")}); a whole-number float counts as an integer`);
|
|
107
|
+
out[name] = Object.freeze([...value]);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
scalarKind(who, name, value);
|
|
111
|
+
out[name] = value;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return Object.freeze(out);
|
|
115
|
+
}
|
|
116
|
+
function checkVersion(who, version) {
|
|
117
|
+
if (typeof version === "bigint") {
|
|
118
|
+
if (version < 1n || version > INT64_MAX)
|
|
119
|
+
throw new GraphError("version", `${who}: version must be a whole number of at least 1 within 64 bits, not ${version}`);
|
|
120
|
+
return version <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(version) : version;
|
|
121
|
+
}
|
|
122
|
+
if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) {
|
|
123
|
+
throw new GraphError("version", `${who}: version must be a whole number of at least 1 (a safe integer, or a bigint within 64 bits), not ${show(version)}`);
|
|
124
|
+
}
|
|
125
|
+
return version;
|
|
126
|
+
}
|
|
127
|
+
function checkId(who, id) {
|
|
128
|
+
if (typeof id !== "string" || id === "")
|
|
129
|
+
throw new GraphError("id", `${who}: id must be a non-empty string`);
|
|
130
|
+
return id;
|
|
131
|
+
}
|
|
132
|
+
function checkEndpoints(who, e) {
|
|
133
|
+
for (const field of ["type", "from", "to"]) {
|
|
134
|
+
if (typeof e[field] !== "string" || e[field] === "")
|
|
135
|
+
throw new GraphError("endpoints", `${who}: an edge needs a type, a from and a to; ${field} is missing or empty`);
|
|
136
|
+
}
|
|
137
|
+
if (!IDENTIFIER.test(e.type))
|
|
138
|
+
throw new GraphError("identifier", `${who}: type ${show(e.type)} is not an identifier ([A-Za-z_][A-Za-z0-9_]*)`);
|
|
139
|
+
return { type: e.type, from: e.from, to: e.to };
|
|
140
|
+
}
|
|
141
|
+
function checkLabels(who, labels) {
|
|
142
|
+
if (labels === undefined)
|
|
143
|
+
return Object.freeze([]);
|
|
144
|
+
if (!Array.isArray(labels))
|
|
145
|
+
throw new GraphError("identifier", `${who}: labels must be a list of identifiers, not ${show(labels)}`);
|
|
146
|
+
for (const label of labels) {
|
|
147
|
+
if (typeof label !== "string" || !IDENTIFIER.test(label))
|
|
148
|
+
throw new GraphError("identifier", `${who}: label ${show(label)} is not an identifier ([A-Za-z_][A-Za-z0-9_]*)`);
|
|
149
|
+
}
|
|
150
|
+
return Object.freeze([...labels]);
|
|
151
|
+
}
|
|
152
|
+
/** The element, checked against everything the merge sink would refuse of it, as a frozen value. */
|
|
153
|
+
function checked(element) {
|
|
154
|
+
const raw = element;
|
|
155
|
+
if (typeof raw !== "object" || raw === null)
|
|
156
|
+
throw new TypeError(`not a graph element: ${show(raw)}`);
|
|
157
|
+
if (raw.kind !== "node" && raw.kind !== "edge" && raw.kind !== "tombstone")
|
|
158
|
+
throw new TypeError(`not a graph element: kind is ${show(raw.kind)}`);
|
|
159
|
+
if (raw.kind === "tombstone" && raw.element !== "node" && raw.element !== "edge")
|
|
160
|
+
throw new TypeError(`not a graph element: a tombstone marks a node or an edge, not ${show(raw.element)}`);
|
|
161
|
+
const who = describe(raw);
|
|
162
|
+
const id = checkId(who, raw.id);
|
|
163
|
+
const version = raw.version === undefined ? {} : { version: checkVersion(who, raw.version) };
|
|
164
|
+
if (raw.kind === "node") {
|
|
165
|
+
return Object.freeze({ kind: "node", id, ...version, labels: checkLabels(who, raw.labels), properties: checkProperties(who, raw.properties) });
|
|
166
|
+
}
|
|
167
|
+
if (raw.kind === "edge") {
|
|
168
|
+
return Object.freeze({ kind: "edge", id, ...version, ...checkEndpoints(who, raw), properties: checkProperties(who, raw.properties) });
|
|
169
|
+
}
|
|
170
|
+
if (raw.element === "edge")
|
|
171
|
+
return Object.freeze({ kind: "tombstone", element: "edge", id, ...version, ...checkEndpoints(who, raw) });
|
|
172
|
+
return Object.freeze({ kind: "tombstone", element: "node", id, ...version });
|
|
173
|
+
}
|
|
174
|
+
/** `this.graph` in a graph consumer: builds elements, refusing where they are built what the sink would refuse. */
|
|
175
|
+
export class Graph {
|
|
176
|
+
node(id, options = {}) {
|
|
177
|
+
return checked({ kind: "node", id, labels: options.labels, properties: options.properties, ...(options.version !== undefined ? { version: options.version } : {}) });
|
|
178
|
+
}
|
|
179
|
+
edge(id, options) {
|
|
180
|
+
return checked({ kind: "edge", id, type: options?.type, from: options?.from, to: options?.to, properties: options?.properties, ...(options?.version !== undefined ? { version: options.version } : {}) });
|
|
181
|
+
}
|
|
182
|
+
/** Marks a node deleted. The node stays in the graph, marked, so a straggling older delta cannot bring it back. */
|
|
183
|
+
tombstoneNode(id, options = {}) {
|
|
184
|
+
return checked({ kind: "tombstone", element: "node", id, ...(options.version !== undefined ? { version: options.version } : {}) });
|
|
185
|
+
}
|
|
186
|
+
/** Marks an edge deleted; it names the edge's type and endpoints so the edge is found without a scan. */
|
|
187
|
+
tombstoneEdge(id, options) {
|
|
188
|
+
return checked({ kind: "tombstone", element: "edge", id, type: options?.type, from: options?.from, to: options?.to, ...(options?.version !== undefined ? { version: options.version } : {}) });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** `this.effects` in a graph consumer: there is nothing here that publishes anything but elements. */
|
|
192
|
+
export class GraphEffects {
|
|
193
|
+
/** Publish these elements, each as a delta under its own key. An empty list publishes nothing. */
|
|
194
|
+
publish(elements) {
|
|
195
|
+
if (!Array.isArray(elements))
|
|
196
|
+
throw new TypeError("publish takes a list of elements");
|
|
197
|
+
return Object.freeze({ kind: "publish", elements: Object.freeze(elements.map(checked)) });
|
|
198
|
+
}
|
|
199
|
+
done() {
|
|
200
|
+
return Object.freeze({ kind: "done" });
|
|
201
|
+
}
|
|
202
|
+
ignore() {
|
|
203
|
+
return Object.freeze({ kind: "ignore" });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// ── writing ──
|
|
207
|
+
function writeScalar(value) {
|
|
208
|
+
switch (typeof value) {
|
|
209
|
+
case "string":
|
|
210
|
+
return JSON.stringify(value);
|
|
211
|
+
case "boolean":
|
|
212
|
+
return value ? "true" : "false";
|
|
213
|
+
case "bigint":
|
|
214
|
+
return value.toString();
|
|
215
|
+
default:
|
|
216
|
+
return Number.isInteger(value) ? String(value) : renderDouble(value);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function writeProperties(properties) {
|
|
220
|
+
const fields = Object.entries(properties).map(([name, value]) => `${JSON.stringify(name)}:${Array.isArray(value) ? `[${value.map(writeScalar).join(",")}]` : writeScalar(value)}`);
|
|
221
|
+
return `{${fields.join(",")}}`;
|
|
222
|
+
}
|
|
223
|
+
/** The delta as the contract writes it. `labels` and `properties` are always written. */
|
|
224
|
+
function writeDelta(delta) {
|
|
225
|
+
const id = JSON.stringify(delta.id);
|
|
226
|
+
const version = delta.version.toString();
|
|
227
|
+
switch (delta.kind) {
|
|
228
|
+
case "node":
|
|
229
|
+
return `{"kind":"node","id":${id},"version":${version},"labels":[${delta.labels.map((l) => JSON.stringify(l)).join(",")}],"properties":${writeProperties(delta.properties)}}`;
|
|
230
|
+
case "edge":
|
|
231
|
+
return `{"kind":"edge","id":${id},"version":${version},"type":${JSON.stringify(delta.type)},"from":${JSON.stringify(delta.from)},"to":${JSON.stringify(delta.to)},"properties":${writeProperties(delta.properties)}}`;
|
|
232
|
+
default:
|
|
233
|
+
return delta.element === "edge"
|
|
234
|
+
? `{"kind":"tombstone","element":"edge","id":${id},"version":${version},"type":${JSON.stringify(delta.type)},"from":${JSON.stringify(delta.from)},"to":${JSON.stringify(delta.to)}}`
|
|
235
|
+
: `{"kind":"tombstone","element":"node","id":${id},"version":${version}}`;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// ── reading ──
|
|
239
|
+
const utf8 = new TextEncoder();
|
|
240
|
+
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
|
241
|
+
function readScalar(name, raw) {
|
|
242
|
+
const refuse = () => {
|
|
243
|
+
throw new Error(`property '${name}' is not a scalar or array of scalars`);
|
|
244
|
+
};
|
|
245
|
+
switch (typeof raw) {
|
|
246
|
+
case "string":
|
|
247
|
+
return { kind: "string", value: raw };
|
|
248
|
+
case "boolean":
|
|
249
|
+
return { kind: "boolean", value: raw };
|
|
250
|
+
case "bigint":
|
|
251
|
+
return raw < INT64_MIN || raw > INT64_MAX ? refuse() : { kind: "integer", value: raw };
|
|
252
|
+
case "number":
|
|
253
|
+
if (!Number.isInteger(raw))
|
|
254
|
+
return { kind: "float", value: raw };
|
|
255
|
+
if (Number.isSafeInteger(raw))
|
|
256
|
+
return { kind: "integer", value: raw };
|
|
257
|
+
// A whole number written with a fraction or an exponent: an integer if it fits 64 bits.
|
|
258
|
+
return raw < Number(INT64_MIN) || raw >= 2 ** 63 ? refuse() : { kind: "integer", value: BigInt(raw) };
|
|
259
|
+
default:
|
|
260
|
+
return refuse();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
function readProperties(raw) {
|
|
264
|
+
if (raw === undefined)
|
|
265
|
+
return Object.freeze({});
|
|
266
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
267
|
+
throw new Error("properties must be an object");
|
|
268
|
+
const out = {};
|
|
269
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
270
|
+
if (RESERVED.has(name))
|
|
271
|
+
throw new Error(`property '${name}' is reserved`);
|
|
272
|
+
if (Array.isArray(value)) {
|
|
273
|
+
const read = value.map((v) => readScalar(name, v));
|
|
274
|
+
if (read.length === 0 || new Set(read.map((r) => r.kind)).size > 1)
|
|
275
|
+
throw new Error(`property '${name}' is not a scalar or array of scalars`);
|
|
276
|
+
out[name] = Object.freeze(read.map((r) => r.value));
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
out[name] = readScalar(name, value).value;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return Object.freeze(out);
|
|
283
|
+
}
|
|
284
|
+
function readVersion(raw) {
|
|
285
|
+
if (typeof raw === "bigint" && raw >= 0n && raw <= INT64_MAX)
|
|
286
|
+
return raw;
|
|
287
|
+
if (typeof raw === "number" && Number.isInteger(raw) && raw >= 0) {
|
|
288
|
+
if (Number.isSafeInteger(raw))
|
|
289
|
+
return raw;
|
|
290
|
+
if (raw < 2 ** 63)
|
|
291
|
+
return BigInt(raw);
|
|
292
|
+
}
|
|
293
|
+
throw new Error("version is not a non-negative integer");
|
|
294
|
+
}
|
|
295
|
+
function readEndpoints(o, problem) {
|
|
296
|
+
const text = (v) => typeof v === "string" && v !== "";
|
|
297
|
+
if (!text(o.type) || !IDENTIFIER.test(o.type) || !text(o.from) || !text(o.to))
|
|
298
|
+
throw new Error(problem);
|
|
299
|
+
return { type: o.type, from: o.from, to: o.to };
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Reads a delta's value back into an element, for tests and for a consumer of a delta topic. Given a
|
|
303
|
+
* `key` it also holds the contract's rule that the key is the delta's element key. It accepts a
|
|
304
|
+
* version of 0, which the contract allows and the builder does not write. An integer past 2⁵³ comes
|
|
305
|
+
* back as a `bigint`.
|
|
306
|
+
*/
|
|
307
|
+
export function readDelta(value, key) {
|
|
308
|
+
let raw;
|
|
309
|
+
try {
|
|
310
|
+
raw = JSON.parse(utf8Decoder.decode(value), reviver);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
throw new Error("not a JSON object");
|
|
314
|
+
}
|
|
315
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
316
|
+
throw new Error("not a JSON object");
|
|
317
|
+
const o = raw;
|
|
318
|
+
if (o.kind === undefined)
|
|
319
|
+
throw new Error("kind missing");
|
|
320
|
+
if (o.kind !== "node" && o.kind !== "edge" && o.kind !== "tombstone")
|
|
321
|
+
throw new Error(`unknown kind '${String(o.kind)}'`);
|
|
322
|
+
if (typeof o.id !== "string" || o.id === "")
|
|
323
|
+
throw new Error("id missing or empty");
|
|
324
|
+
const id = o.id;
|
|
325
|
+
const version = readVersion(o.version);
|
|
326
|
+
let delta;
|
|
327
|
+
if (o.kind === "node") {
|
|
328
|
+
const labels = o.labels ?? [];
|
|
329
|
+
if (!Array.isArray(labels) || labels.some((l) => typeof l !== "string" || !IDENTIFIER.test(l)))
|
|
330
|
+
throw new Error("labels must be an array of identifiers");
|
|
331
|
+
delta = Object.freeze({ kind: "node", id, version, labels: Object.freeze([...labels]), properties: readProperties(o.properties) });
|
|
332
|
+
}
|
|
333
|
+
else if (o.kind === "edge") {
|
|
334
|
+
delta = Object.freeze({ kind: "edge", id, version, ...readEndpoints(o, "edge needs type, from and to"), properties: readProperties(o.properties) });
|
|
335
|
+
}
|
|
336
|
+
else if (o.element === "node") {
|
|
337
|
+
delta = Object.freeze({ kind: "tombstone", element: "node", id, version });
|
|
338
|
+
}
|
|
339
|
+
else if (o.element === "edge") {
|
|
340
|
+
delta = Object.freeze({ kind: "tombstone", element: "edge", id, version, ...readEndpoints(o, "tombstone of an edge needs type, from and to") });
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
throw new Error("tombstone needs element 'node' or 'edge'");
|
|
344
|
+
}
|
|
345
|
+
if (key !== undefined && key !== elementKey(delta))
|
|
346
|
+
throw new Error(`key '${key}' is not this delta's element key '${elementKey(delta)}'`);
|
|
347
|
+
return delta;
|
|
348
|
+
}
|
|
349
|
+
/** The codec of a delta: JSON under the manifest `ankka.graph-delta.v1`. Hand-written, since a delta's properties have no fixed shape. */
|
|
350
|
+
export const graphDeltaCodec = Object.freeze({
|
|
351
|
+
manifest: GRAPH_DELTA_SCHEMA,
|
|
352
|
+
contentType: JSON_CONTENT,
|
|
353
|
+
encode: (delta) => utf8.encode(writeDelta(checked(delta))),
|
|
354
|
+
decode: (bytes) => readDelta(bytes),
|
|
355
|
+
});
|
|
356
|
+
/**
|
|
357
|
+
* What a graph consumer's effect publishes for the change `metadata` describes: every element at its
|
|
358
|
+
* stated version, else the change's sequence number, refusing a result that names an element twice or
|
|
359
|
+
* leaves a version to a change that has no sequence number. The server and the test kit both read an
|
|
360
|
+
* effect through this, so they cannot disagree about what a handler published.
|
|
361
|
+
*
|
|
362
|
+
* @internal
|
|
363
|
+
*/
|
|
364
|
+
export function deltaRecords(effect, metadata) {
|
|
365
|
+
if (effect.kind !== "publish")
|
|
366
|
+
return [];
|
|
367
|
+
const sequence = sequenceNumberOf(metadata);
|
|
368
|
+
const seen = new Set();
|
|
369
|
+
return effect.elements.map((given) => {
|
|
370
|
+
const element = checked(given);
|
|
371
|
+
const key = elementKey(element);
|
|
372
|
+
if (seen.has(key))
|
|
373
|
+
throw new GraphError("duplicate", `${describe(element)} is in this result twice (${key}); an element is published once for a change`);
|
|
374
|
+
seen.add(key);
|
|
375
|
+
let version = element.version;
|
|
376
|
+
if (version === undefined) {
|
|
377
|
+
if (sequence === undefined || sequence < 1n) {
|
|
378
|
+
throw new GraphError("no-sequence", `${describe(element)}: this source has no sequence number; state a version`);
|
|
379
|
+
}
|
|
380
|
+
version = checkVersion(describe(element), sequence);
|
|
381
|
+
}
|
|
382
|
+
const delta = Object.freeze({ ...element, version });
|
|
383
|
+
return Object.freeze({ key, value: utf8.encode(writeDelta(delta)), delta });
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
// ── the consumer ──
|
|
387
|
+
/**
|
|
388
|
+
* A consumer that publishes a graph. Its handlers return elements; each is published to `producesTo`
|
|
389
|
+
* as a delta under its element key. It has no `produce` and no way to set a key, and it is discovered
|
|
390
|
+
* and hosted as an ordinary consumer.
|
|
391
|
+
*/
|
|
392
|
+
export class GraphConsumer {
|
|
393
|
+
graph = new Graph();
|
|
394
|
+
effects = new GraphEffects();
|
|
395
|
+
#metadata = {};
|
|
396
|
+
#client;
|
|
397
|
+
/** The change's metadata: `ce-subject` is the source instance's id, `ankka.sequence` its sequence number. */
|
|
398
|
+
get metadata() {
|
|
399
|
+
return this.#metadata;
|
|
400
|
+
}
|
|
401
|
+
get subject() {
|
|
402
|
+
return this.#metadata["ce-subject"] ?? "";
|
|
403
|
+
}
|
|
404
|
+
/** The change's sequence number, which is the version of every element that states none. */
|
|
405
|
+
get sequenceNumber() {
|
|
406
|
+
return sequenceNumberOf(this.#metadata);
|
|
407
|
+
}
|
|
408
|
+
get client() {
|
|
409
|
+
if (!this.#client)
|
|
410
|
+
throw new Error("client is only available inside onMessage or onDelete");
|
|
411
|
+
return this.#client;
|
|
412
|
+
}
|
|
413
|
+
/** @internal */
|
|
414
|
+
get _kind() {
|
|
415
|
+
return "consumer";
|
|
416
|
+
}
|
|
417
|
+
/** The source was deleted. Ignored unless this says otherwise; publish tombstones here. */
|
|
418
|
+
onDelete() {
|
|
419
|
+
return this.effects.ignore();
|
|
420
|
+
}
|
|
421
|
+
/** @internal */
|
|
422
|
+
_bind(metadata, client) {
|
|
423
|
+
this.#metadata = metadata;
|
|
424
|
+
this.#client = client;
|
|
425
|
+
}
|
|
426
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -11,9 +11,10 @@ export { UpdateBuilder, KeyValueEffects, type UpdateEffect, type KeyValueReadOnl
|
|
|
11
11
|
export { KeyValueEntity, type KeyValueEntityClass } from "./keyValueEntity.ts";
|
|
12
12
|
export { WorkflowEffects, StepEffects, WorkflowUpdateBuilder, StepUpdateBuilder, workflowSettings, type WorkflowEffect, type WorkflowReadOnlyEffect, type WorkflowCommandEffect, type StepEffect, type StepOutcome, type StepRef, type PauseOptions, type WorkflowSettings, type StepSettings, type Recovery, } from "./effects/workflow.ts";
|
|
13
13
|
export { Workflow, type WorkflowClass } from "./workflow.ts";
|
|
14
|
-
export { ViewEffects, ConsumerEffects, TimedActionEffects, type ViewEffect, type ConsumerEffect, type TimedActionEffect } from "./effects/stateless.ts";
|
|
14
|
+
export { ViewEffects, ConsumerEffects, TimedActionEffects, type ViewEffect, type ConsumerEffect, type OutgoingMessage, type TimedActionEffect } from "./effects/stateless.ts";
|
|
15
15
|
export { View, type ViewClass } from "./view.ts";
|
|
16
|
-
export { Consumer, type ConsumerClass } from "./consumer.ts";
|
|
16
|
+
export { Consumer, ProtocolVersionError, type ConsumerClass } from "./consumer.ts";
|
|
17
|
+
export { GRAPH_DELTA_SCHEMA, Graph, GraphConsumer, GraphEffects, GraphError, edgeKey, elementKey, graphDeltaCodec, nodeKey, readDelta, type Delta, type Element, type GraphConsumerClass, type GraphEffect, type GraphFault, type Properties, type PropertyValue, type Scalar, type Version, } from "./graph.ts";
|
|
17
18
|
export { TimedAction, type TimedActionClass } from "./timedAction.ts";
|
|
18
19
|
export { AgentEffects, type AgentEffect } from "./effects/agent.ts";
|
|
19
20
|
export { Agent, type AgentClass } from "./agent.ts";
|
package/dist/index.js
CHANGED
|
@@ -24,7 +24,8 @@ export { WorkflowEffects, StepEffects, WorkflowUpdateBuilder, StepUpdateBuilder,
|
|
|
24
24
|
export { Workflow } from "./workflow.js";
|
|
25
25
|
export { ViewEffects, ConsumerEffects, TimedActionEffects } from "./effects/stateless.js";
|
|
26
26
|
export { View } from "./view.js";
|
|
27
|
-
export { Consumer } from "./consumer.js";
|
|
27
|
+
export { Consumer, ProtocolVersionError } from "./consumer.js";
|
|
28
|
+
export { GRAPH_DELTA_SCHEMA, Graph, GraphConsumer, GraphEffects, GraphError, edgeKey, elementKey, graphDeltaCodec, nodeKey, readDelta, } from "./graph.js";
|
|
28
29
|
export { TimedAction } from "./timedAction.js";
|
|
29
30
|
export { AgentEffects } from "./effects/agent.js";
|
|
30
31
|
export { Agent } from "./agent.js";
|
package/dist/server/stateless.js
CHANGED
|
@@ -8,8 +8,12 @@ import { TimedAction as TimedActionService, TimedActionEffectSchema } from "../_
|
|
|
8
8
|
import { codecFor } from "../codec.js";
|
|
9
9
|
import { metadataFromProto, metadataToProto } from "../context.js";
|
|
10
10
|
import { ErrorCode } from "../effects/common.js";
|
|
11
|
+
import { ProtocolVersionError, requireSeveralMessages } from "../consumer.js";
|
|
12
|
+
import { GRAPH_DELTA_SCHEMA, deltaRecords } from "../graph.js";
|
|
13
|
+
import { JSON_CONTENT } from "../codec.js";
|
|
11
14
|
import { errorCodeToProto } from "../kinds.js";
|
|
12
15
|
import { decodePayload, encodePayload } from "./payloads.js";
|
|
16
|
+
import { PayloadSchema } from "../_proto/ankka/protocol/v1/payload_pb.js";
|
|
13
17
|
function messageOf(e) {
|
|
14
18
|
return e instanceof Error ? `${e.name}: ${e.message}` : String(e);
|
|
15
19
|
}
|
|
@@ -41,33 +45,79 @@ export async function handleView(req, ctx) {
|
|
|
41
45
|
throw new ConnectError(messageOf(e), Code.Internal);
|
|
42
46
|
}
|
|
43
47
|
}
|
|
48
|
+
const DONE = () => create(ConsumerEffectSchema, { effect: { case: "done", value: {} } });
|
|
49
|
+
const IGNORE = () => create(ConsumerEffectSchema, { effect: { case: "ignore", value: {} } });
|
|
50
|
+
/**
|
|
51
|
+
* Several messages as the reply `produce_all`, which only a runtime at protocol 1.3 understands: the
|
|
52
|
+
* request must have said so. None at all is `done`, which every runtime understands and means the same.
|
|
53
|
+
*/
|
|
54
|
+
function produceAll(request, messages) {
|
|
55
|
+
if (messages.length === 0)
|
|
56
|
+
return DONE();
|
|
57
|
+
requireSeveralMessages(request);
|
|
58
|
+
return create(ConsumerEffectSchema, {
|
|
59
|
+
effect: {
|
|
60
|
+
case: "produceAll",
|
|
61
|
+
value: { messages: messages.map((m) => ({ payload: m.payload, metadata: metadataToProto(m.metadata), ...(m.key !== undefined ? { key: m.key } : {}) })) },
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
async function graphEffect(registered, req, metadata, ctx) {
|
|
66
|
+
const consumer = new registered.cls();
|
|
67
|
+
consumer._bind(metadata, ctx.client.withMetadata(metadata));
|
|
68
|
+
const effect = req.deleted ? await consumer.onDelete() : await consumer.onMessage(decodePayload(registered.messageCodec, req.message));
|
|
69
|
+
switch (effect.kind) {
|
|
70
|
+
case "publish":
|
|
71
|
+
return produceAll(metadata, deltaRecords(effect, metadata).map((r) => ({
|
|
72
|
+
payload: create(PayloadSchema, { contentType: JSON_CONTENT, manifest: GRAPH_DELTA_SCHEMA, data: r.value }),
|
|
73
|
+
metadata: { "ce-type": GRAPH_DELTA_SCHEMA },
|
|
74
|
+
key: r.key,
|
|
75
|
+
})));
|
|
76
|
+
case "done":
|
|
77
|
+
return DONE();
|
|
78
|
+
case "ignore":
|
|
79
|
+
return IGNORE();
|
|
80
|
+
default:
|
|
81
|
+
throw new TypeError(`${registered.id}.onMessage returned something that is not a graph effect`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function consumerEffect(registered, req, metadata, ctx) {
|
|
85
|
+
const consumer = new registered.cls();
|
|
86
|
+
consumer._bind(metadata, ctx.client.withMetadata(metadata));
|
|
87
|
+
const effect = req.deleted ? await consumer.onDelete() : await consumer.onMessage(decodePayload(registered.messageCodec, req.message));
|
|
88
|
+
const outCodec = () => {
|
|
89
|
+
if (!registered.outCodec)
|
|
90
|
+
throw new Error(`${registered.id} produced a message but declares no out shape`);
|
|
91
|
+
return registered.outCodec;
|
|
92
|
+
};
|
|
93
|
+
switch (effect.kind) {
|
|
94
|
+
case "produce":
|
|
95
|
+
return create(ConsumerEffectSchema, { effect: { case: "produce", value: { payload: encodePayload(outCodec(), effect.payload), metadata: metadataToProto(effect.metadata) } } });
|
|
96
|
+
case "produceAll":
|
|
97
|
+
return produceAll(metadata, effect.messages.map((m) => ({ payload: encodePayload(outCodec(), m.payload), metadata: m.metadata ?? {}, ...(m.key !== undefined ? { key: m.key } : {}) })));
|
|
98
|
+
case "done":
|
|
99
|
+
return DONE();
|
|
100
|
+
case "ignore":
|
|
101
|
+
return IGNORE();
|
|
102
|
+
default:
|
|
103
|
+
throw new TypeError(`${registered.id}.onMessage returned something that is not a consumer effect`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
44
106
|
export async function handleConsumer(req, ctx) {
|
|
45
107
|
const registered = ctx.registry.of("consumer", req.componentId);
|
|
46
108
|
if (!registered)
|
|
47
109
|
throw new ConnectError(`no consumer ${JSON.stringify(req.componentId)} is registered`, Code.NotFound);
|
|
48
110
|
const metadata = metadataFromProto(req.metadata);
|
|
49
|
-
const consumer = new registered.cls();
|
|
50
111
|
try {
|
|
51
|
-
|
|
52
|
-
const effect = req.deleted ? await consumer.onDelete() : await consumer.onMessage(decodePayload(registered.messageCodec, req.message));
|
|
53
|
-
switch (effect.kind) {
|
|
54
|
-
case "produce": {
|
|
55
|
-
if (!registered.outCodec)
|
|
56
|
-
throw new Error(`${registered.id} produced a message but declares no out shape`);
|
|
57
|
-
return create(ConsumerEffectSchema, { effect: { case: "produce", value: { payload: encodePayload(registered.outCodec, effect.payload), metadata: metadataToProto(effect.metadata) } } });
|
|
58
|
-
}
|
|
59
|
-
case "done":
|
|
60
|
-
return create(ConsumerEffectSchema, { effect: { case: "done", value: {} } });
|
|
61
|
-
case "ignore":
|
|
62
|
-
return create(ConsumerEffectSchema, { effect: { case: "ignore", value: {} } });
|
|
63
|
-
default:
|
|
64
|
-
throw new TypeError(`${registered.id}.onMessage returned something that is not a consumer effect`);
|
|
65
|
-
}
|
|
112
|
+
return registered.graph ? await graphEffect(registered, req, metadata, ctx) : await consumerEffect(registered, req, metadata, ctx);
|
|
66
113
|
}
|
|
67
114
|
catch (e) {
|
|
68
115
|
if (e instanceof ConnectError)
|
|
69
116
|
throw e;
|
|
70
117
|
ctx.log(`ankka: consumer ${registered.id} on ${metadata["ce-subject"] ?? "?"} threw: ${messageOf(e)}`);
|
|
118
|
+
// The message alone, so the runtime's log says what the SDK needs and of which runtime.
|
|
119
|
+
if (e instanceof ProtocolVersionError)
|
|
120
|
+
throw new ConnectError(e.message, Code.Internal);
|
|
71
121
|
throw new ConnectError(messageOf(e), Code.Internal);
|
|
72
122
|
}
|
|
73
123
|
}
|
package/dist/service.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { KeyValueEntity, type KeyValueEntityClass } from "./keyValueEntity.ts";
|
|
|
4
4
|
import { Workflow, type WorkflowClass } from "./workflow.ts";
|
|
5
5
|
import { View, type ViewClass } from "./view.ts";
|
|
6
6
|
import { Consumer, type ConsumerClass } from "./consumer.ts";
|
|
7
|
+
import { GraphConsumer, type GraphConsumerClass } from "./graph.ts";
|
|
7
8
|
import { TimedAction, type TimedActionClass } from "./timedAction.ts";
|
|
8
9
|
import { Agent, type AgentClass } from "./agent.ts";
|
|
9
10
|
import { AutonomousAgent, type AutonomousAgentClass, type TaskType } from "./autonomous.ts";
|
|
@@ -61,11 +62,13 @@ export interface RegisteredView {
|
|
|
61
62
|
export interface RegisteredConsumer {
|
|
62
63
|
readonly kind: "consumer";
|
|
63
64
|
readonly id: string;
|
|
64
|
-
readonly cls: ConsumerClass<any, any, any>;
|
|
65
|
+
readonly cls: ConsumerClass<any, any, any> | GraphConsumerClass<any, any>;
|
|
65
66
|
readonly source: Source;
|
|
66
67
|
readonly messageCodec: Codec<any>;
|
|
67
68
|
readonly outCodec: Codec<any> | undefined;
|
|
68
69
|
readonly producesTo: string | undefined;
|
|
70
|
+
/** A graph consumer: its handlers return elements, each published as a delta under its element key. */
|
|
71
|
+
readonly graph: boolean;
|
|
69
72
|
}
|
|
70
73
|
export interface RegisteredTimedAction {
|
|
71
74
|
readonly kind: "timed-action";
|
|
@@ -134,6 +137,7 @@ export declare class ServiceBuilder {
|
|
|
134
137
|
register<S, C extends Workflow<S>>(cls: WorkflowClass<S, C>): this;
|
|
135
138
|
register<E, Row, C extends View<E, Row>>(cls: ViewClass<E, Row, C>): this;
|
|
136
139
|
register<M, Out, C extends Consumer<M, Out>>(cls: ConsumerClass<M, Out, C>): this;
|
|
140
|
+
register<M, C extends GraphConsumer<M>>(cls: GraphConsumerClass<M, C>): this;
|
|
137
141
|
register<C extends TimedAction>(cls: TimedActionClass<C>): this;
|
|
138
142
|
register<C extends Agent>(cls: AgentClass<C>): this;
|
|
139
143
|
register<C extends AutonomousAgent>(cls: AutonomousAgentClass<C>): this;
|
package/dist/service.js
CHANGED
|
@@ -9,6 +9,7 @@ import { KeyValueEntity } from "./keyValueEntity.js";
|
|
|
9
9
|
import { Workflow } from "./workflow.js";
|
|
10
10
|
import { View } from "./view.js";
|
|
11
11
|
import { Consumer } from "./consumer.js";
|
|
12
|
+
import { GraphConsumer, graphDeltaCodec } from "./graph.js";
|
|
12
13
|
import { TimedAction } from "./timedAction.js";
|
|
13
14
|
import { Agent } from "./agent.js";
|
|
14
15
|
import { AutonomousAgent, COMPLETE_TASK, FAIL_TASK } from "./autonomous.js";
|
|
@@ -97,6 +98,8 @@ export class ServiceBuilder {
|
|
|
97
98
|
add(registerView(cls, problems), cls);
|
|
98
99
|
else if (extendsBase(cls, Consumer))
|
|
99
100
|
add(registerConsumer(cls, problems), cls);
|
|
101
|
+
else if (extendsBase(cls, GraphConsumer))
|
|
102
|
+
add(registerGraphConsumer(cls, problems), cls);
|
|
100
103
|
else if (extendsBase(cls, TimedAction))
|
|
101
104
|
add(registerTimedAction(cls, problems), cls);
|
|
102
105
|
else if (extendsBase(cls, Agent))
|
|
@@ -120,7 +123,7 @@ export class ServiceBuilder {
|
|
|
120
123
|
}
|
|
121
124
|
}
|
|
122
125
|
else {
|
|
123
|
-
problems.push(`${nameOf(cls)} is not a component class: it must extend EventSourcedEntity, KeyValueEntity, Workflow, View, Consumer, TimedAction, Agent, AutonomousAgent or Endpoint`);
|
|
126
|
+
problems.push(`${nameOf(cls)} is not a component class: it must extend EventSourcedEntity, KeyValueEntity, Workflow, View, Consumer, GraphConsumer, TimedAction, Agent, AutonomousAgent or Endpoint`);
|
|
124
127
|
}
|
|
125
128
|
}
|
|
126
129
|
if (problems.length > 0)
|
|
@@ -274,6 +277,30 @@ function registerConsumer(cls, problems) {
|
|
|
274
277
|
messageCodec: codecFor(cls.message),
|
|
275
278
|
outCodec: cls.out ? codecFor(cls.out) : undefined,
|
|
276
279
|
producesTo: cls.producesTo,
|
|
280
|
+
graph: false,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
/** A graph consumer is a consumer to discovery and to the runtime: one that produces deltas to its topic. */
|
|
284
|
+
function registerGraphConsumer(cls, problems) {
|
|
285
|
+
const { fail, ok } = checker(cls, problems);
|
|
286
|
+
requireId(cls, fail);
|
|
287
|
+
const source = sourceOf(cls, fail);
|
|
288
|
+
requireShape(cls.message, "message: a schema or codec for the source's messages", fail);
|
|
289
|
+
if (typeof cls.producesTo !== "string" || cls.producesTo.trim() === "")
|
|
290
|
+
fail("needs a static producesTo: the topic its deltas are published to");
|
|
291
|
+
if (cls.out !== undefined)
|
|
292
|
+
fail("declares a static out, which a graph consumer does not have: what it produces is graph deltas");
|
|
293
|
+
if (!ok() || !source)
|
|
294
|
+
return undefined;
|
|
295
|
+
return Object.freeze({
|
|
296
|
+
kind: "consumer",
|
|
297
|
+
id: cls.componentId,
|
|
298
|
+
cls,
|
|
299
|
+
source,
|
|
300
|
+
messageCodec: codecFor(cls.message),
|
|
301
|
+
outCodec: graphDeltaCodec,
|
|
302
|
+
producesTo: cls.producesTo,
|
|
303
|
+
graph: true,
|
|
277
304
|
});
|
|
278
305
|
}
|
|
279
306
|
function registerTimedAction(cls, problems) {
|
package/dist/spec.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type MessageInitShape } from "@bufbuild/protobuf";
|
|
|
2
2
|
import { Endpoint_Acl, type CallerMatcherSchema, type Spec } from "./_proto/ankka/protocol/v1/discovery_pb.ts";
|
|
3
3
|
import type { Registry } from "./service.ts";
|
|
4
4
|
import type { Acl } from "./routes.ts";
|
|
5
|
-
export declare const PROTOCOL_VERSION = "1.
|
|
5
|
+
export declare const PROTOCOL_VERSION = "1.3";
|
|
6
6
|
export declare const SDK_NAME = "ankka-typescript";
|
|
7
7
|
export declare function aclToProto(acl: Acl): Endpoint_Acl;
|
|
8
8
|
/** The callers a CALLERS acl names, as discovery carries them; empty for every other rule. */
|
package/dist/spec.js
CHANGED
|
@@ -6,7 +6,7 @@ import { kindToProto } from "./kinds.js";
|
|
|
6
6
|
import { toJsonSchema } from "./schema.js";
|
|
7
7
|
import { isCodec } from "./codec.js";
|
|
8
8
|
import { VERSION } from "./version.js";
|
|
9
|
-
export const PROTOCOL_VERSION = "1.
|
|
9
|
+
export const PROTOCOL_VERSION = "1.3";
|
|
10
10
|
export const SDK_NAME = "ankka-typescript";
|
|
11
11
|
export function aclToProto(acl) {
|
|
12
12
|
if (typeof acl === "object")
|
package/dist/testkit/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { EventSourcedTestKit, EndpointTestKit, Response, type RequestOptions, type Materialised } from "./unit.ts";
|
|
2
|
-
export { KeyValueTestKit, WorkflowTestKit, ViewTestKit, ConsumerTestKit, TimedActionTestKit, AgentTestKit, ScriptedModel, type WorkflowProgress, type ModelResponse, type ModelCall, } from "./kinds.ts";
|
|
2
|
+
export { KeyValueTestKit, WorkflowTestKit, ViewTestKit, ConsumerTestKit, GraphConsumerTestKit, TimedActionTestKit, AgentTestKit, ScriptedModel, type WorkflowProgress, type ModelResponse, type ModelCall, type GraphChange, } from "./kinds.ts";
|
|
3
3
|
export { AnkkaTestKit, PUBLISHED_SIDECAR, sidecarImage, type AnkkaTestKitOptions, type HttpResponse, type Http, type Beside } from "./integration.ts";
|
package/dist/testkit/index.js
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
// (Postgres and the real sidecar image in Docker). The integration testkit's Docker dependencies,
|
|
3
3
|
// `testcontainers` and `@testcontainers/postgresql`, are loaded only when `AnkkaTestKit.start` runs.
|
|
4
4
|
export { EventSourcedTestKit, EndpointTestKit, Response } from "./unit.js";
|
|
5
|
-
export { KeyValueTestKit, WorkflowTestKit, ViewTestKit, ConsumerTestKit, TimedActionTestKit, AgentTestKit, ScriptedModel, } from "./kinds.js";
|
|
5
|
+
export { KeyValueTestKit, WorkflowTestKit, ViewTestKit, ConsumerTestKit, GraphConsumerTestKit, TimedActionTestKit, AgentTestKit, ScriptedModel, } from "./kinds.js";
|
|
6
6
|
export { AnkkaTestKit, PUBLISHED_SIDECAR, sidecarImage } from "./integration.js";
|
package/dist/testkit/kinds.d.ts
CHANGED
|
@@ -5,7 +5,8 @@ import type { ConsumerEffect, TimedActionEffect, ViewEffect } from "../effects/s
|
|
|
5
5
|
import type { KeyValueEntity, KeyValueEntityClass } from "../keyValueEntity.ts";
|
|
6
6
|
import type { Workflow, WorkflowClass } from "../workflow.ts";
|
|
7
7
|
import type { View, ViewClass } from "../view.ts";
|
|
8
|
-
import type
|
|
8
|
+
import { type Consumer, type ConsumerClass } from "../consumer.ts";
|
|
9
|
+
import { type Delta, type GraphConsumerClass } from "../graph.ts";
|
|
9
10
|
import type { TimedAction, TimedActionClass } from "../timedAction.ts";
|
|
10
11
|
import type { Agent, AgentClass } from "../agent.ts";
|
|
11
12
|
import type { HandlerRef } from "../handlers.ts";
|
|
@@ -62,17 +63,45 @@ export declare class ViewTestKit<E, Row, C extends View<E, Row>> {
|
|
|
62
63
|
/** Feeds a consumer messages and collects what it produced. */
|
|
63
64
|
export declare class ConsumerTestKit<M, Out, C extends Consumer<M, Out>> {
|
|
64
65
|
#private;
|
|
65
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Everything the consumer produced, one entry per message, round-tripped through the out codec.
|
|
68
|
+
* `key` is the record key a message of `produceAll` named; absent, the message is keyed by its subject.
|
|
69
|
+
*/
|
|
66
70
|
readonly produced: {
|
|
67
71
|
readonly payload: Out;
|
|
68
72
|
readonly metadata: Metadata;
|
|
73
|
+
readonly key?: string;
|
|
69
74
|
}[];
|
|
70
75
|
private constructor();
|
|
71
76
|
static of<M, Out, C extends Consumer<M, Out>>(cls: ConsumerClass<M, Out, C>, client?: ComponentClient): ConsumerTestKit<M, Out, C>;
|
|
72
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* A message from source instance `subject`. The metadata says the runtime speaks this SDK's protocol
|
|
79
|
+
* version; pass `"ankka.protocol"` or `"ankka.sequence"` to say otherwise.
|
|
80
|
+
*/
|
|
73
81
|
onMessage(message: M, subject?: string, metadata?: Metadata): Promise<ConsumerEffect<Out>>;
|
|
74
82
|
onDelete(subject?: string, metadata?: Metadata): Promise<ConsumerEffect<Out>>;
|
|
75
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Feeds a graph consumer changes and answers with the elements it published: each read back from the
|
|
86
|
+
* bytes that would be published, under the key it would be published under, so a test asserts on what
|
|
87
|
+
* a reader of the topic sees.
|
|
88
|
+
*/
|
|
89
|
+
export declare class GraphConsumerTestKit<M> {
|
|
90
|
+
#private;
|
|
91
|
+
private constructor();
|
|
92
|
+
static of<M>(cls: GraphConsumerClass<M>, client?: ComponentClient): GraphConsumerTestKit<M>;
|
|
93
|
+
/** A change from source instance `subject` (default `"test"`) at `sequence` (default 1). */
|
|
94
|
+
onMessage(message: M, options?: GraphChange): Promise<Delta[]>;
|
|
95
|
+
/** The source instance was deleted, at `sequence`. */
|
|
96
|
+
onDelete(options?: GraphChange): Promise<Delta[]>;
|
|
97
|
+
}
|
|
98
|
+
/** The change a graph consumer is handed: whose it is and where in that instance's history. */
|
|
99
|
+
export interface GraphChange {
|
|
100
|
+
readonly subject?: string;
|
|
101
|
+
/** The change's sequence number; `0` is a source with none, as a topic is. */
|
|
102
|
+
readonly sequence?: number | bigint;
|
|
103
|
+
readonly metadata?: Metadata;
|
|
104
|
+
}
|
|
76
105
|
/** Invokes a timed action's handlers as the sweeper would. */
|
|
77
106
|
export declare class TimedActionTestKit<C extends TimedAction> {
|
|
78
107
|
#private;
|
package/dist/testkit/kinds.js
CHANGED
|
@@ -5,6 +5,9 @@ import { ComponentClient, noClient } from "../client.js";
|
|
|
5
5
|
import { binaryCodecs, codecFor } from "../codec.js";
|
|
6
6
|
import { commandContext } from "../context.js";
|
|
7
7
|
import { CommandError, ErrorCode } from "../effects/common.js";
|
|
8
|
+
import { PROTOCOL_KEY, requireSeveralMessages } from "../consumer.js";
|
|
9
|
+
import { deltaRecords, readDelta } from "../graph.js";
|
|
10
|
+
import { PROTOCOL_VERSION } from "../spec.js";
|
|
8
11
|
import { materialiseKeyValue, materialiseStep, materialiseWorkflowCommand } from "../materialise.js";
|
|
9
12
|
import { Ankka } from "../service.js";
|
|
10
13
|
import { checkPlan, runTool } from "../server/agent.js";
|
|
@@ -229,7 +232,10 @@ export class ConsumerTestKit {
|
|
|
229
232
|
#registered;
|
|
230
233
|
#cls;
|
|
231
234
|
#client;
|
|
232
|
-
/**
|
|
235
|
+
/**
|
|
236
|
+
* Everything the consumer produced, one entry per message, round-tripped through the out codec.
|
|
237
|
+
* `key` is the record key a message of `produceAll` named; absent, the message is keyed by its subject.
|
|
238
|
+
*/
|
|
233
239
|
produced = [];
|
|
234
240
|
constructor(cls, client) {
|
|
235
241
|
this.#registered = registryFor(cls, client).component(cls.componentId);
|
|
@@ -243,21 +249,75 @@ export class ConsumerTestKit {
|
|
|
243
249
|
const consumer = new this.#cls();
|
|
244
250
|
consumer._bind(metadata, this.#client.withMetadata(metadata));
|
|
245
251
|
const effect = await fn(consumer);
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
if (!
|
|
252
|
+
const outCodec = () => {
|
|
253
|
+
const codec = this.#registered.outCodec;
|
|
254
|
+
if (!codec)
|
|
249
255
|
throw new Error(`${this.#registered.id} produced a message but declares no out shape`);
|
|
250
|
-
|
|
256
|
+
return codec;
|
|
257
|
+
};
|
|
258
|
+
if (effect.kind === "produce") {
|
|
259
|
+
this.produced.push({ payload: roundTrip(outCodec(), effect.payload), metadata: effect.metadata });
|
|
260
|
+
}
|
|
261
|
+
else if (effect.kind === "produceAll" && effect.messages.length > 0) {
|
|
262
|
+
// The server's rule: several messages go only to a runtime that said it accepts them.
|
|
263
|
+
requireSeveralMessages(metadata);
|
|
264
|
+
for (const m of effect.messages) {
|
|
265
|
+
this.produced.push({ payload: roundTrip(outCodec(), m.payload), metadata: m.metadata ?? {}, ...(m.key !== undefined ? { key: m.key } : {}) });
|
|
266
|
+
}
|
|
251
267
|
}
|
|
252
268
|
return effect;
|
|
253
269
|
}
|
|
254
|
-
/**
|
|
270
|
+
/**
|
|
271
|
+
* A message from source instance `subject`. The metadata says the runtime speaks this SDK's protocol
|
|
272
|
+
* version; pass `"ankka.protocol"` or `"ankka.sequence"` to say otherwise.
|
|
273
|
+
*/
|
|
255
274
|
onMessage(message, subject = "test", metadata = {}) {
|
|
256
275
|
const wire = roundTrip(this.#registered.messageCodec, message);
|
|
257
|
-
return this.#apply((c) => c.onMessage(wire), { "ce-subject": subject, ...metadata });
|
|
276
|
+
return this.#apply((c) => c.onMessage(wire), { "ce-subject": subject, [PROTOCOL_KEY]: PROTOCOL_VERSION, ...metadata });
|
|
258
277
|
}
|
|
259
278
|
onDelete(subject = "test", metadata = {}) {
|
|
260
|
-
return this.#apply((c) => c.onDelete(), { "ce-subject": subject, ...metadata });
|
|
279
|
+
return this.#apply((c) => c.onDelete(), { "ce-subject": subject, [PROTOCOL_KEY]: PROTOCOL_VERSION, ...metadata });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Feeds a graph consumer changes and answers with the elements it published: each read back from the
|
|
284
|
+
* bytes that would be published, under the key it would be published under, so a test asserts on what
|
|
285
|
+
* a reader of the topic sees.
|
|
286
|
+
*/
|
|
287
|
+
export class GraphConsumerTestKit {
|
|
288
|
+
#registered;
|
|
289
|
+
#cls;
|
|
290
|
+
#client;
|
|
291
|
+
constructor(cls, client) {
|
|
292
|
+
this.#registered = registryFor(cls, client).component(cls.componentId);
|
|
293
|
+
this.#cls = cls;
|
|
294
|
+
this.#client = client;
|
|
295
|
+
}
|
|
296
|
+
static of(cls, client = noClient()) {
|
|
297
|
+
return new GraphConsumerTestKit(cls, client);
|
|
298
|
+
}
|
|
299
|
+
async #apply(fn, options) {
|
|
300
|
+
const metadata = {
|
|
301
|
+
"ce-subject": options.subject ?? "test",
|
|
302
|
+
"ankka.sequence": String(options.sequence ?? 1),
|
|
303
|
+
[PROTOCOL_KEY]: PROTOCOL_VERSION,
|
|
304
|
+
...options.metadata,
|
|
305
|
+
};
|
|
306
|
+
const consumer = new this.#cls();
|
|
307
|
+
consumer._bind(metadata, this.#client.withMetadata(metadata));
|
|
308
|
+
const records = deltaRecords(await fn(consumer), metadata);
|
|
309
|
+
if (records.length > 0)
|
|
310
|
+
requireSeveralMessages(metadata);
|
|
311
|
+
return records.map((r) => readDelta(r.value, r.key));
|
|
312
|
+
}
|
|
313
|
+
/** A change from source instance `subject` (default `"test"`) at `sequence` (default 1). */
|
|
314
|
+
onMessage(message, options = {}) {
|
|
315
|
+
const wire = roundTrip(this.#registered.messageCodec, message);
|
|
316
|
+
return this.#apply((c) => c.onMessage(wire), options);
|
|
317
|
+
}
|
|
318
|
+
/** The source instance was deleted, at `sequence`. */
|
|
319
|
+
onDelete(options = {}) {
|
|
320
|
+
return this.#apply((c) => c.onDelete(), options);
|
|
261
321
|
}
|
|
262
322
|
}
|
|
263
323
|
/** Invokes a timed action's handlers as the sweeper would. */
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.10.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.
|
|
2
|
+
export const VERSION = "0.10.0";
|
package/package.json
CHANGED