Describing Events with Schemas
Every event type has a JSON schema, which the database checks every event of the type against, once the schema is registered (see Registering Event Schemas).
The kit derives it from the struct, so that it describes exactly what encoding/json writes for the event:
- A struct is an object with the fields
encoding/jsonwrites: named by theirjsontags, without fields tagged-and unexported ones, and with the fields of embedded structs in place of the embedded struct. Fields withomitemptyoromitzero, and fields of an embedded pointer, are optional, all others are required, and no other fields are allowed. - A
stringis a string, aboola boolean, anintor any other integer type an integer, and afloat64or afloat32a number. Thestringoption of ajsontag turns such a field into a string. - A slice is an array, a
[]bytea string, and an array an array of exactly its length. A map is an object whose values all have the same schema. - A pointer, a slice and a map may also be
null, sinceencoding/jsonwritesnullfornil, unless a field of such a type is optional and therefore left out instead. - A
time.Timeis a string in thedate-timeformat, ajson.Numbera number, unless thestringoption makes it a string, and a type with aMarshalTextor anAppendTextfunction a string. An interface allows any value, even if it declares aSchemafunction. So does ajson.RawMessage, which is the same type asjsontext.Valueofencoding/json/v2, sinceencoding/jsonwrites the JSON it holds, ornullif it isnil.
For BookBorrowed, this yields the following schema:
{
"type": "object",
"properties": {
"borrowedBy": { "type": "string" },
"borrowedUntil": { "type": "string" }
},
"required": ["borrowedBy", "borrowedUntil"],
"additionalProperties": false
}
These rules do not change, since a registered schema can not change either.
Note that EventSourcingDB currently reads every number in the data of an event as a 64-bit floating-point number, which holds an integer exactly only if its magnitude is at most 2^53 - 1, which is 9007199254740991. A larger integer is stored changed, without an error: 9007199254740993 comes back as 9007199254740992, and a time in nanoseconds, as UnixNano returns it, loses its last digits. Close to the largest int64 or uint64, the stored number may even lie beyond the range of the type, so that the event can not be read back any more, and Load, Execute, and a projection fail with an error of the category ErrPermanent. To keep a 64-bit integer exact, give its field the string option of the json tag, as in json:"atNanos,string", which writes it as a string.
To constrain a value further than its Go type does, declare a type for it with a Schema function, which returns the JSON schema of the type. Wherever a field has that type, the derived schema takes it over. For example, borrowedUntil accepts any string so far. To make sure that it is a date, BookBorrowed could use a Date type for the field:
type Date string
func (Date) Schema() map[string]any {
return map[string]any{"type": "string", "format": "date"}
}
type BookBorrowed struct {
BorrowedBy string `json:"borrowedBy"`
BorrowedUntil Date `json:"borrowedUntil"`
}
Note that the other examples in this documentation keep BorrowedUntil a string. With the Date type, they would convert between the two, as in Date(cmd.BorrowedUntil).
Apart from time.Time, json.Number and json.RawMessage, a type that encodes itself with a MarshalJSON function, or with MarshalJSONTo of encoding/json/v2, which encoding/json calls as well, needs such a Schema function, too, since the kit can not know what the function writes. So does a type that has its MarshalText or AppendText function on a pointer receiver only: encoding/json calls it only for a value it can take the address of, so whether such a value is written as a string depends on where it is. If the schema of an event can not be derived, for example because of such a type, a recursive type, a channel, a function, or a complex number, Evolve or Ignore panics and names the field.
Note that the derived schema never takes over the Schema function of an interface. encoding/json writes the value a field of the interface holds, or null, so there is no value whose Schema function could be asked, and the field is described as if the interface had none. To constrain such a field, or one whose interface encodes itself with MarshalJSON, give the event a Schema function of its own.
Note that a field of type json.RawMessage is constrained the same way, with a Schema function of the event. A type declared from json.RawMessage, as in type Details json.RawMessage, does not help, since it does not take over the MarshalJSON function: encoding/json writes such a value as a []byte, which is a string.
If an event needs a schema that its fields can not express, give the event itself a Schema function. It takes precedence over the derived schema. To start from the derived schema, call the DeriveSchema function, which derives the schema of a type without calling its own Schema function. For example, to require at least one of two optional fields:
type BookCorrected struct {
Title string `json:"title,omitempty"`
Author string `json:"author,omitempty"`
}
func (BookCorrected) EventType() string {
return "io.eventsourcingdb.library.book-corrected"
}
func (BookCorrected) Schema() map[string]any {
schema := architecturekit.DeriveSchema[BookCorrected]()
schema["minProperties"] = 1
return schema
}
DeriveSchema returns a new schema on every call, with objects as map[string]any, arrays as []any, and numbers as float64, such as the minItems of an array, as encoding/json decodes them.
A Schema function describes the type that declares it. Go also promotes it to a struct that embeds the type, but there it still describes the embedded type alone, while encoding/json writes the fields of the embedded struct next to the other fields of the struct. So if an event, or the type of a field, has its Schema function only from an embedded field, Evolve or Ignore panics and names the embedded field, and so does DeriveSchema for such a type. Give the struct a Schema function of its own, or make the embedded type a named field.
Note that the derived schema describes the fields of an embedded struct by their own types, as it describes all other fields, even if the embedded struct has a Schema function. A struct that starts its own Schema function from DeriveSchema therefore does not get the constraints from the Schema function of the embedded struct. Add them there if the struct needs them.
Note that a json tag name that encoding/json considers invalid also makes Evolve or Ignore panic, since encoding/json reads such a name differently depending on the Go version the application declares.
Keeping Schemas in Files#
An event's own schema does not have to be written in Go. To keep it in a JSON file instead, embed the file with the embed package, and decode it in the Schema function of the event:
import (
_ "embed"
"encoding/json"
)
//go:embed schemas/book-acquired.json
var bookAcquiredSchema []byte
func (BookAcquired) Schema() map[string]any {
var schema map[string]any
if err := json.Unmarshal(bookAcquiredSchema, &schema); err != nil {
panic(err)
}
return schema
}
The Evolve and Ignore functions call Schema when the state is built, so a file that does not contain valid JSON stops the application on start, before it writes anything.