feat: update aggregate implementation

This commit is contained in:
2025-08-11 13:34:28 +02:00
parent 9dddc4e79f
commit cc8c558db6
37 changed files with 361 additions and 511 deletions

View File

@@ -11,66 +11,6 @@ repository to one or more distibuted services.
The following provides a quick introduction on how to get started.
### Configs
Events are defined in `json` configuration files which we print to a generated `events.ts` file that is used by the
event store instance we are using. To do this, start by creating a new folder that will house our event configurations.
```sh
$ mkdir events
$ cd events
```
Now add a new event configuration file.
```sh
$ touch user-created.json
```
Open the file and add the event details.
```json
{
"event": {
"type": "user:created",
"data": {
"name": {
"type": "object",
"properties": {
"given": {
"type": "string"
},
"family": {
"type": "string"
}
}
},
"email": {
"type": "string"
}
},
"meta": {
"auditor": {
"type": "string"
}
}
}
}
```
### Generate
To create our `events.ts` file we have to run our configurations through our event printer.
```ts
import { printEvents } from "@valkyr/event-store";
await printEvents({
inputs: ["./configs/events"],
outputs: ["./generated/events.ts"],
});
```
### Event Store
Once we have defined our configs and printed our events we create a new event store instance. Currently we have support

View File

@@ -1,4 +1,4 @@
import z from "zod/v4";
import z from "zod";
import type { CollectionRegistrar } from "../types.ts";

View File

@@ -1,4 +1,4 @@
import z from "zod/v4";
import z from "zod";
import type { CollectionRegistrar } from "../types.ts";

View File

@@ -1,4 +1,4 @@
import z from "zod/v4";
import z from "zod";
import type { CollectionRegistrar } from "../types.ts";

View File

@@ -1,5 +1,5 @@
import type { Db, WithId } from "mongodb";
import type { z, ZodObject } from "zod/v4";
import type { z, ZodObject } from "zod";
/**
* Take a list of records and run it through the given zod parser. This

View File

@@ -1,6 +1,6 @@
{
"name": "@valkyr/event-store",
"version": "2.0.0-beta.4",
"version": "2.0.0-beta.5",
"exports": {
".": "./mod.ts",
"./browser": "./adapters/browser/adapter.ts",

171
deno.lock generated
View File

@@ -1,23 +1,23 @@
{
"version": "5",
"specifiers": {
"npm:@jsr/std__assert@1.0.13": "1.0.13",
"npm:@jsr/std__async@1.0.13": "1.0.13",
"npm:@jsr/std__testing@1.0.14": "1.0.14",
"npm:@jsr/valkyr__testcontainers@2": "2.0.1",
"npm:@jsr/std__assert@1": "1.0.13",
"npm:@jsr/std__async@1": "1.0.14",
"npm:@jsr/std__testing@1": "1.0.15",
"npm:@jsr/valkyr__testcontainers@2": "2.0.2",
"npm:@valkyr/db@1.0.1": "1.0.1",
"npm:eslint-plugin-simple-import-sort@12.1.1": "12.1.1_eslint@9.30.1",
"npm:eslint@9.30.1": "9.30.1",
"npm:fake-indexeddb@6.0.1": "6.0.1",
"npm:mongodb@6": "6.17.0",
"npm:eslint-plugin-simple-import-sort@12": "12.1.1_eslint@9.33.0",
"npm:eslint@9": "9.33.0",
"npm:fake-indexeddb@6": "6.1.0",
"npm:mongodb@6": "6.18.0",
"npm:nanoid@5": "5.1.5",
"npm:postgres@3": "3.4.7",
"npm:prettier@3.6.2": "3.6.2",
"npm:typescript-eslint@8.35.1": "8.35.1_eslint@9.30.1_typescript@5.8.3_@typescript-eslint+parser@8.35.1__eslint@9.30.1__typescript@5.8.3",
"npm:zod@3.25": "3.25.75"
"npm:prettier@3": "3.6.2",
"npm:typescript-eslint@8": "8.39.0_eslint@9.33.0_typescript@5.9.2_@typescript-eslint+parser@8.39.0__eslint@9.33.0__typescript@5.9.2",
"npm:zod@4": "4.0.17"
},
"npm": {
"@eslint-community/eslint-utils@4.7.0_eslint@9.30.1": {
"@eslint-community/eslint-utils@4.7.0_eslint@9.33.0": {
"integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
"dependencies": [
"eslint",
@@ -35,17 +35,11 @@
"minimatch@3.1.2"
]
},
"@eslint/config-helpers@0.3.0": {
"integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw=="
"@eslint/config-helpers@0.3.1": {
"integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA=="
},
"@eslint/core@0.14.0": {
"integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==",
"dependencies": [
"@types/json-schema"
]
},
"@eslint/core@0.15.1": {
"integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==",
"@eslint/core@0.15.2": {
"integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
"dependencies": [
"@types/json-schema"
]
@@ -64,16 +58,16 @@
"strip-json-comments"
]
},
"@eslint/js@9.30.1": {
"integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg=="
"@eslint/js@9.33.0": {
"integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A=="
},
"@eslint/object-schema@2.1.6": {
"integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="
},
"@eslint/plugin-kit@0.3.3": {
"integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==",
"@eslint/plugin-kit@0.3.5": {
"integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
"dependencies": [
"@eslint/core@0.15.1",
"@eslint/core",
"levn"
]
},
@@ -103,13 +97,16 @@
],
"tarball": "https://npm.jsr.io/~/11/@jsr/std__assert/1.0.13.tgz"
},
"@jsr/std__async@1.0.13": {
"integrity": "sha512-GEApyNtzauJ0kEZ/GxebSkdEN0t29qJtkw+WEvzYTwkL6fHX8cq3YWzRjCqHu+4jMl+rpHiwyr/lfitNInntzA==",
"tarball": "https://npm.jsr.io/~/11/@jsr/std__async/1.0.13.tgz"
"@jsr/std__async@1.0.14": {
"integrity": "sha512-aIG8W3TOmW+lKdAJA5w56qASu9EiUmBXbhW6eAlSEUBid+KVESGqQygFFg+awt/c8K+qobVM6M/u3SbIy0NyUQ==",
"tarball": "https://npm.jsr.io/~/11/@jsr/std__async/1.0.14.tgz"
},
"@jsr/std__data-structures@1.0.8": {
"integrity": "sha512-7BHBUlBEJ/9w2zv9sNmyuQOINBTEP1erxLHMpIDBa7GMCV1Nxm6LvgC4R5cgN90FFKpoCFa9PPB66Hkeem9Q2g==",
"tarball": "https://npm.jsr.io/~/11/@jsr/std__data-structures/1.0.8.tgz"
"@jsr/std__data-structures@1.0.9": {
"integrity": "sha512-+mT4Nll6fx+CPNqrlC+huhIOYNSMS+KUdJ4B8NujiQrh/bq++ds5PXpEsfV5EPR+YuWcuDGG0P1DE+Rednd7Wg==",
"dependencies": [
"@jsr/std__assert"
],
"tarball": "https://npm.jsr.io/~/11/@jsr/std__data-structures/1.0.9.tgz"
},
"@jsr/std__fs@1.0.19": {
"integrity": "sha512-TEjyE8g+46jPlu7dJHLrwc8NMGl8zfG+JjWxyNQyDbxP0RtqZ4JmYZfR9vy4RWYWJQbLpw6Kbt2n+K/2zAO/JA==",
@@ -119,9 +116,9 @@
],
"tarball": "https://npm.jsr.io/~/11/@jsr/std__fs/1.0.19.tgz"
},
"@jsr/std__internal@1.0.9": {
"integrity": "sha512-s+f4qrJzZgPAy7XuFOtgaSaxyPLnnEmAfXGLvRXGxPTL76URLVHkF+hOzqXz+bmk8/awybF6BRsasxtAQOV23Q==",
"tarball": "https://npm.jsr.io/~/11/@jsr/std__internal/1.0.9.tgz"
"@jsr/std__internal@1.0.10": {
"integrity": "sha512-fmD6yKep/sMnB2yPQU/REZG7Z4N9SZwcUBNnceo4QkXk67l3JEfxHoROQ/YHeVSOmq6x55Ra6nuMjz2ib3nj3g==",
"tarball": "https://npm.jsr.io/~/11/@jsr/std__internal/1.0.10.tgz"
},
"@jsr/std__net@1.0.4": {
"integrity": "sha512-KJGU8ZpQ70sMW2Zk+wU3wFUkggS9lTLfRFBygnV9VaK8KI+1ggiqtB06rH4a14CNRGM9y46Mn/ZCbQUd4Q45Jg==",
@@ -134,8 +131,8 @@
],
"tarball": "https://npm.jsr.io/~/11/@jsr/std__path/1.1.1.tgz"
},
"@jsr/std__testing@1.0.14": {
"integrity": "sha512-WQ2ctU3AmV0dcaVEahIUfz4e+3+Y3UMyqFLjCZ6JKeI40zkDpeMFBsTop7e7ptGE4wgHrQj+FETh9XAgEuBlZA==",
"@jsr/std__testing@1.0.15": {
"integrity": "sha512-NgQuXxTEG4ecbh2fzYbkJWJoBgPXwbv6bdsrAYSOeLpX2d+TROEzpErbWQXHi/yxZy/FNn9IF548ZDAqMZxi/g==",
"dependencies": [
"@jsr/std__assert",
"@jsr/std__async",
@@ -144,10 +141,10 @@
"@jsr/std__internal",
"@jsr/std__path"
],
"tarball": "https://npm.jsr.io/~/11/@jsr/std__testing/1.0.14.tgz"
"tarball": "https://npm.jsr.io/~/11/@jsr/std__testing/1.0.15.tgz"
},
"@jsr/valkyr__testcontainers@2.0.1": {
"integrity": "sha512-HInqMkCDj1ICrcz+Led/3jyLa70mwncxdlly8v/5WepuPW3gszKftq5U2jbjy2THOYUP7ibBK2o0recg7qhvcw==",
"@jsr/valkyr__testcontainers@2.0.2": {
"integrity": "sha512-YnmfraYFr3msoUGrIFeElm03nbQqXOaPu0QUT6JI3w6/mIYpVfzPxghkB7gn2RIc81QgrqjwKJE/AL3dltlR1w==",
"dependencies": [
"@jsr/std__async",
"@jsr/std__fs",
@@ -155,7 +152,7 @@
"mongodb",
"postgres"
],
"tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__testcontainers/2.0.1.tgz"
"tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__testcontainers/2.0.2.tgz"
},
"@mongodb-js/saslprep@1.3.0": {
"integrity": "sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ==",
@@ -195,8 +192,8 @@
"@types/webidl-conversions"
]
},
"@typescript-eslint/eslint-plugin@8.35.1_@typescript-eslint+parser@8.35.1__eslint@9.30.1__typescript@5.8.3_eslint@9.30.1_typescript@5.8.3": {
"integrity": "sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg==",
"@typescript-eslint/eslint-plugin@8.39.0_@typescript-eslint+parser@8.39.0__eslint@9.33.0__typescript@5.9.2_eslint@9.33.0_typescript@5.9.2": {
"integrity": "sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==",
"dependencies": [
"@eslint-community/regexpp",
"@typescript-eslint/parser",
@@ -212,8 +209,8 @@
"typescript"
]
},
"@typescript-eslint/parser@8.35.1_eslint@9.30.1_typescript@5.8.3": {
"integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==",
"@typescript-eslint/parser@8.39.0_eslint@9.33.0_typescript@5.9.2": {
"integrity": "sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==",
"dependencies": [
"@typescript-eslint/scope-manager",
"@typescript-eslint/types",
@@ -224,8 +221,8 @@
"typescript"
]
},
"@typescript-eslint/project-service@8.35.1_typescript@5.8.3": {
"integrity": "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q==",
"@typescript-eslint/project-service@8.39.0_typescript@5.9.2": {
"integrity": "sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==",
"dependencies": [
"@typescript-eslint/tsconfig-utils",
"@typescript-eslint/types",
@@ -233,22 +230,23 @@
"typescript"
]
},
"@typescript-eslint/scope-manager@8.35.1": {
"integrity": "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg==",
"@typescript-eslint/scope-manager@8.39.0": {
"integrity": "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==",
"dependencies": [
"@typescript-eslint/types",
"@typescript-eslint/visitor-keys"
]
},
"@typescript-eslint/tsconfig-utils@8.35.1_typescript@5.8.3": {
"integrity": "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ==",
"@typescript-eslint/tsconfig-utils@8.39.0_typescript@5.9.2": {
"integrity": "sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==",
"dependencies": [
"typescript"
]
},
"@typescript-eslint/type-utils@8.35.1_eslint@9.30.1_typescript@5.8.3": {
"integrity": "sha512-HOrUBlfVRz5W2LIKpXzZoy6VTZzMu2n8q9C2V/cFngIC5U1nStJgv0tMV4sZPzdf4wQm9/ToWUFPMN9Vq9VJQQ==",
"@typescript-eslint/type-utils@8.39.0_eslint@9.33.0_typescript@5.9.2": {
"integrity": "sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q==",
"dependencies": [
"@typescript-eslint/types",
"@typescript-eslint/typescript-estree",
"@typescript-eslint/utils",
"debug",
@@ -257,11 +255,11 @@
"typescript"
]
},
"@typescript-eslint/types@8.35.1": {
"integrity": "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ=="
"@typescript-eslint/types@8.39.0": {
"integrity": "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg=="
},
"@typescript-eslint/typescript-estree@8.35.1_typescript@5.8.3": {
"integrity": "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g==",
"@typescript-eslint/typescript-estree@8.39.0_typescript@5.9.2": {
"integrity": "sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==",
"dependencies": [
"@typescript-eslint/project-service",
"@typescript-eslint/tsconfig-utils",
@@ -276,8 +274,8 @@
"typescript"
]
},
"@typescript-eslint/utils@8.35.1_eslint@9.30.1_typescript@5.8.3": {
"integrity": "sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ==",
"@typescript-eslint/utils@8.39.0_eslint@9.33.0_typescript@5.9.2": {
"integrity": "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==",
"dependencies": [
"@eslint-community/eslint-utils",
"@typescript-eslint/scope-manager",
@@ -287,8 +285,8 @@
"typescript"
]
},
"@typescript-eslint/visitor-keys@8.35.1": {
"integrity": "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==",
"@typescript-eslint/visitor-keys@8.39.0": {
"integrity": "sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==",
"dependencies": [
"@typescript-eslint/types",
"eslint-visitor-keys@4.2.1"
@@ -407,7 +405,7 @@
"escape-string-regexp@4.0.0": {
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
},
"eslint-plugin-simple-import-sort@12.1.1_eslint@9.30.1": {
"eslint-plugin-simple-import-sort@12.1.1_eslint@9.33.0": {
"integrity": "sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==",
"dependencies": [
"eslint"
@@ -426,14 +424,14 @@
"eslint-visitor-keys@4.2.1": {
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="
},
"eslint@9.30.1": {
"integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==",
"eslint@9.33.0": {
"integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==",
"dependencies": [
"@eslint-community/eslint-utils",
"@eslint-community/regexpp",
"@eslint/config-array",
"@eslint/config-helpers",
"@eslint/core@0.14.0",
"@eslint/core",
"@eslint/eslintrc",
"@eslint/js",
"@eslint/plugin-kit",
@@ -493,8 +491,8 @@
"esutils@2.0.3": {
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
},
"fake-indexeddb@6.0.1": {
"integrity": "sha512-He2AjQGHe46svIFq5+L2Nx/eHDTI1oKgoevBP+TthnjymXiKkeJQ3+ITeWey99Y5+2OaPFbI1qEsx/5RsGtWnQ=="
"fake-indexeddb@6.1.0": {
"integrity": "sha512-gOzajWIhEug/CQHUIxigKT9Zilh5/I6WvUBez6/UdUtT/YVEHM9r572Os8wfvhp7TkmgBtRNdqSM7YoCXWMzZg=="
},
"fast-deep-equal@3.1.3": {
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
@@ -681,8 +679,8 @@
"whatwg-url"
]
},
"mongodb@6.17.0": {
"integrity": "sha512-neerUzg/8U26cgruLysKEjJvoNSXhyID3RvzvdcpsIi2COYM3FS3o9nlH7fxFtefTb942dX3W9i37oPfCVj4wA==",
"mongodb@6.18.0": {
"integrity": "sha512-fO5ttN9VC8P0F5fqtQmclAkgXZxbIkYRTUi1j8JO6IYwvamkhtYDilJr35jOPELR49zqCJgXZWwCtW7B+TM8vQ==",
"dependencies": [
"@mongodb-js/saslprep",
"bson",
@@ -818,7 +816,7 @@
"punycode"
]
},
"ts-api-utils@2.1.0_typescript@5.8.3": {
"ts-api-utils@2.1.0_typescript@5.9.2": {
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
"dependencies": [
"typescript"
@@ -836,18 +834,19 @@
"type-fest@3.13.1": {
"integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="
},
"typescript-eslint@8.35.1_eslint@9.30.1_typescript@5.8.3_@typescript-eslint+parser@8.35.1__eslint@9.30.1__typescript@5.8.3": {
"integrity": "sha512-xslJjFzhOmHYQzSB/QTeASAHbjmxOGEP6Coh93TXmUBFQoJ1VU35UHIDmG06Jd6taf3wqqC1ntBnCMeymy5Ovw==",
"typescript-eslint@8.39.0_eslint@9.33.0_typescript@5.9.2_@typescript-eslint+parser@8.39.0__eslint@9.33.0__typescript@5.9.2": {
"integrity": "sha512-lH8FvtdtzcHJCkMOKnN73LIn6SLTpoojgJqDAxPm1jCR14eWSGPX8ul/gggBdPMk/d5+u9V854vTYQ8T5jF/1Q==",
"dependencies": [
"@typescript-eslint/eslint-plugin",
"@typescript-eslint/parser",
"@typescript-eslint/typescript-estree",
"@typescript-eslint/utils",
"eslint",
"typescript"
]
},
"typescript@5.8.3": {
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"typescript@5.9.2": {
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"bin": true
},
"uri-js@4.4.1": {
@@ -879,27 +878,27 @@
"yocto-queue@0.1.0": {
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
},
"zod@3.25.75": {
"integrity": "sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg=="
"zod@4.0.17": {
"integrity": "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ=="
}
},
"workspace": {
"packageJson": {
"dependencies": [
"npm:@jsr/std__assert@1.0.13",
"npm:@jsr/std__async@1.0.13",
"npm:@jsr/std__testing@1.0.14",
"npm:@jsr/std__assert@1",
"npm:@jsr/std__async@1",
"npm:@jsr/std__testing@1",
"npm:@jsr/valkyr__testcontainers@2",
"npm:@valkyr/db@1.0.1",
"npm:eslint-plugin-simple-import-sort@12.1.1",
"npm:eslint@9.30.1",
"npm:fake-indexeddb@6.0.1",
"npm:eslint-plugin-simple-import-sort@12",
"npm:eslint@9",
"npm:fake-indexeddb@6",
"npm:mongodb@6",
"npm:nanoid@5",
"npm:postgres@3",
"npm:prettier@3.6.2",
"npm:typescript-eslint@8.35.1",
"npm:zod@3.25"
"npm:prettier@3",
"npm:typescript-eslint@8",
"npm:zod@4"
]
}
}

View File

@@ -1,74 +0,0 @@
import { AggregateRootClass } from "./aggregate.ts";
import { EventFactory } from "./event-factory.ts";
import { AnyEventStore } from "./event-store.ts";
/**
* Indexes a list of event factories for use with aggregates and event stores
* when generating or accessing event functionality.
*
* @example
*
* ```ts
* import { AggregateRoot, AggregateFactory } from "@valkyr/event-store";
* import z from "zod";
*
* class User extends AggregateRoot {}
*
* const factory = new AggregateFactory([User]);
*
* export type Aggregates = typeof factory.$aggregates;
* ```
*/
export class AggregateFactory<
const TEventFactory extends EventFactory = EventFactory,
const TAggregates extends AggregateRootClass<TEventFactory>[] = AggregateRootClass<TEventFactory>[],
> {
/**
* Optimized aggregate lookup index.
*/
readonly #index = new Map<TAggregates[number]["name"], TAggregates[number]>();
aggregates: TAggregates;
/**
* Inferred type of the aggregates registered with the factory.
*/
declare readonly $aggregates: TAggregates;
/**
* Instantiate a new AggregateFactory with given list of supported aggregates.
*
* @param aggregates - Aggregates to register with the factory.
*/
constructor(aggregates: TAggregates) {
this.aggregates = aggregates;
for (const aggregate of aggregates) {
this.#index.set(aggregate.name, aggregate);
}
}
/**
* Attaches the given store to all the aggregates registered with this instance.
*
* If the factory is passed into multiple event stores, the aggregates will be
* overriden by the last execution. Its recommended to create individual instances
* for each list of aggregates.
*
* @param store - Event store to attach to the aggregates.
*/
withStore(store: AnyEventStore): this {
for (const aggregate of this.aggregates) {
aggregate.$store = store;
}
return this;
}
/**
* Get a registered aggregate from the factory.
*
* @param name - Aggregate to retrieve.
*/
get<TName extends TAggregates[number]["name"]>(name: TName): Extract<TAggregates[number], { name: TName }> {
return this.#index.get(name) as Extract<TAggregates[number], { name: TName }>;
}
}

View File

@@ -1,6 +1,8 @@
import type { AnyEventStore, EventsInsertSettings } from "../libraries/event-store.ts";
import type { Unknown } from "../types/common.ts";
import { AggregateSnapshotViolation, AggregateStreamViolation } from "./errors.ts";
import { EventFactory } from "./event-factory.ts";
import { makeAggregateReducer } from "./reducer.ts";
/**
* Represents an aggregate root in an event-sourced system.
@@ -18,39 +20,43 @@ export abstract class AggregateRoot<TEventFactory extends EventFactory> {
*/
static readonly name: string;
readonly #store: AnyEventStore;
/**
* Event store to transact against.
* Primary unique identifier for the stream the aggregate belongs to.
*/
protected static _store?: AnyEventStore;
#stream?: string;
/**
* List of pending records to push to the parent event store.
*/
#pending: TEventFactory["$events"][number]["$record"][] = [];
/**
* Instantiate a new AggregateRoot with a given event store instance.
*
* @param store - Store this aggregate instance acts against.
*/
constructor(store: AnyEventStore) {
this.#store = store;
}
// -------------------------------------------------------------------------
// Accessors
// -------------------------------------------------------------------------
static get $store(): AnyEventStore {
if (this._store === undefined) {
throw new Error(`Aggregate Root > Failed to retrieve store for '${this.name}', no store has been attached.`);
set id(value: string) {
if (this.#stream !== undefined) {
throw new AggregateStreamViolation(this.constructor.name);
}
return this._store;
this.#stream = value;
}
static set $store(store: AnyEventStore) {
// if (this._store !== undefined) {
// throw new Error(`Aggregate '${this.constructor.name}' already has store assigned`);
// }
this._store = store;
}
/**
* Get store instance attached to the static aggregate.
*/
get $store(): AnyEventStore {
return (this.constructor as any).$store;
get id() {
if (this.#stream === undefined) {
this.#stream = crypto.randomUUID();
}
return this.#stream;
}
/**
@@ -74,9 +80,10 @@ export abstract class AggregateRoot<TEventFactory extends EventFactory> {
*/
static from<TEventFactory extends EventFactory, TAggregateRoot extends typeof AggregateRoot<TEventFactory>>(
this: TAggregateRoot,
store: AnyEventStore,
snapshot?: Unknown,
): InstanceType<TAggregateRoot> {
const instance = new (this as any)();
const instance = new (this as any)(store);
if (snapshot !== undefined) {
Object.assign(instance, snapshot);
}
@@ -109,7 +116,7 @@ export abstract class AggregateRoot<TEventFactory extends EventFactory> {
push<TType extends TEventFactory["$events"][number]["state"]["type"]>(
record: { type: TType } & Extract<TEventFactory["$events"][number], { state: { type: TType } }>["$payload"],
): this {
const pending = this.$store.event(record);
const pending = this.#store.event(record);
this.#pending.push(pending);
this.with(pending);
return this;
@@ -136,13 +143,25 @@ export abstract class AggregateRoot<TEventFactory extends EventFactory> {
if (this.isDirty === false) {
return this;
}
await this.$store.pushManyEvents(this.#pending, settings);
await this.#store.pushManyEvents(this.#pending, settings);
if (flush === true) {
this.flush();
}
return this;
}
async snapshot() {
const stream = this.#stream;
if (stream === undefined) {
throw new AggregateSnapshotViolation((this.constructor as typeof AggregateRoot<TEventFactory>).name);
}
await this.#store.createSnapshot({
name: this.constructor.name,
stream,
reducer: makeAggregateReducer(this.#store, this.constructor as typeof AggregateRoot<TEventFactory>),
});
}
/**
* Removes all events from the aggregate #pending list.
*/

View File

@@ -1,3 +1,42 @@
/*
|--------------------------------------------------------------------------------
| Aggregate Errors
|--------------------------------------------------------------------------------
*/
/**
* Error thrown when stream assignment on the aggregate has already been set.
*
* @property name - Name of the aggregate throwing the error.
*/
export class AggregateStreamViolation extends Error {
readonly type = "AggregateStreamAlreadySet";
constructor(name: string) {
super(`EventStore Error: Aggregate '${name}' already has a stream assigned, overriding not supported.`);
}
}
/**
* Error thrown when attempting to snapshot an aggregate without a resolved
* stream.
*
* @property name - Name of the aggregate throwing the error.
*/
export class AggregateSnapshotViolation extends Error {
readonly type = "AggregateSnapshotViolation";
constructor(name: string) {
super(`EventStore Error: Aggregate '${name}' has no stream assigned, snapshot generation cannot be executed.`);
}
}
/*
|--------------------------------------------------------------------------------
| Event Errors
|--------------------------------------------------------------------------------
*/
/**
* Error thrown when an expected event is missing from the event store.
*
@@ -14,12 +53,6 @@ export class EventMissingError extends Error {
}
}
/*
|--------------------------------------------------------------------------------
| Event Errors
|--------------------------------------------------------------------------------
*/
/**
* Error thrown when an event fails validation checks.
*

View File

@@ -35,8 +35,7 @@
import { EventStoreAdapter } from "../types/adapter.ts";
import type { Unknown } from "../types/common.ts";
import type { EventReadOptions, ReduceQuery } from "../types/query.ts";
import type { AggregateRoot } from "./aggregate.ts";
import { AggregateFactory } from "./aggregate-factory.ts";
import { AggregateRootClass } from "./aggregate.ts";
import { EventInsertionError, EventMissingError, EventValidationError } from "./errors.ts";
import { EventStatus } from "./event.ts";
import { EventFactory } from "./event-factory.ts";
@@ -53,24 +52,21 @@ import { makeAggregateReducer, makeReducer } from "./reducer.ts";
* Provides a common interface to interact with a event storage solution. Its built
* on an adapter pattern to allow for multiple different storage drivers.
*/
export class EventStore<
TEventFactory extends EventFactory,
TAggregateFactory extends AggregateFactory<TEventFactory>,
TEventStoreAdapter extends EventStoreAdapter<any>,
> {
export class EventStore<TEventFactory extends EventFactory, TEventStoreAdapter extends EventStoreAdapter<any>> {
readonly uuid: string;
readonly #adapter: TEventStoreAdapter;
readonly #events: TEventFactory;
readonly #aggregates: TAggregateFactory;
readonly #snapshot: "manual" | "auto";
readonly #hooks: EventStoreHooks<TEventFactory>;
declare readonly $events: TEventFactory["$events"];
declare readonly $records: TEventFactory["$events"][number]["$record"][];
constructor(config: EventStoreConfig<TEventFactory, TAggregateFactory, TEventStoreAdapter>) {
constructor(config: EventStoreConfig<TEventFactory, TEventStoreAdapter>) {
this.uuid = crypto.randomUUID();
this.#adapter = config.adapter;
this.#events = config.events;
this.#aggregates = config.aggregates.withStore(this);
this.#snapshot = config.snapshot ?? "manual";
this.#hooks = config.hooks ?? {};
}
@@ -113,55 +109,82 @@ export class EventStore<
|--------------------------------------------------------------------------------
*/
/**
* Get aggregate uninstantiated class.
*
* @param name - Aggregate name to retrieve.
*/
aggregate<TName extends TAggregateFactory["$aggregates"][number]["name"]>(
name: TName,
): Extract<TAggregateFactory["$aggregates"][number], { name: TName }> {
return this.#aggregates.get(name) as Extract<TAggregateFactory["$aggregates"][number], { name: TName }>;
}
readonly aggregate = {
/**
* Takes a list of aggregates and commits any pending events to the event store.
* Events are committed in order so its important to ensure that the aggregates
* are placed in the correct index position of the array.
*
* This method allows for a simpler way to commit many events over many
* aggregates in a single transaction. Ensuring atomicity of a larger group
* of events.
*
* @param aggregates - Aggregates to push events from.
* @param settings - Event settings which can modify insertion behavior.
*/
push: async (
aggregates: InstanceType<AggregateRootClass<TEventFactory>>[],
settings?: EventsInsertSettings,
): Promise<void> => {
const events: this["$events"][number]["$record"][] = [];
for (const aggregate of aggregates) {
events.push(...aggregate.toPending());
}
await this.pushManyEvents(events, settings);
for (const aggregate of aggregates) {
aggregate.flush();
}
},
/**
* Takes in an aggregate and commits any pending events to the event store.
*
* @param aggregate - Aggregate to push events from.
* @param settings - Event settings which can modify insertion behavior.
*/
async pushAggregate(
aggregate: InstanceType<TAggregateFactory["$aggregates"][number]>,
settings?: EventsInsertSettings,
): Promise<void> {
await aggregate.save(settings);
}
/**
* Get a new aggregate instance by a given stream.
*
* @param name - Aggregate to instantiate.
* @param stream - Stream to retrieve snapshot from.
*/
getByStream: async <TAggregate extends AggregateRootClass<TEventFactory>>(
aggregate: TAggregate,
stream: string,
): Promise<InstanceType<TAggregate> | undefined> => {
const reducer = makeAggregateReducer(this, aggregate);
const snapshot = await this.reduce({ name: aggregate.name, stream, reducer });
if (snapshot === undefined) {
return undefined;
}
return aggregate.from(this, snapshot as Unknown);
},
/**
* Takes a list of aggregates and commits any pending events to the event store.
* Events are committed in order so its important to ensure that the aggregates
* are placed in the correct index position of the array.
*
* This method allows for a simpler way to commit many events over many
* aggregates in a single transaction. Ensuring atomicity of a larger group
* of events.
*
* @param aggregates - Aggregates to push events from.
* @param settings - Event settings which can modify insertion behavior.
*/
async pushManyAggregates(
aggregates: InstanceType<TAggregateFactory["$aggregates"][number]>[],
settings?: EventsInsertSettings,
): Promise<void> {
const events: this["$events"][number]["$record"][] = [];
for (const aggregate of aggregates) {
events.push(...aggregate.toPending());
}
await this.pushManyEvents(events, settings);
for (const aggregate of aggregates) {
aggregate.flush();
}
}
/**
* Get a new aggregate instance by a given relation.
*
* @param name - Aggregate to instantiate.
* @param relation - Relation to retrieve snapshot from.
*/
getByRelation: async <TAggregate extends AggregateRootClass<TEventFactory>>(
aggregate: TAggregate,
relation: string,
): Promise<InstanceType<TAggregate> | undefined> => {
const reducer = makeAggregateReducer(this, aggregate);
const snapshot = await this.reduce({ name: aggregate.name, relation, reducer });
if (snapshot === undefined) {
return undefined;
}
return aggregate.from(this, snapshot as Unknown);
},
/**
* Instantiate a new aggreate.
*
* @param aggregate - Aggregate to instantiate.
* @param snapshot - Optional snapshot to instantiate aggregate with.
*/
from: <TAggregate extends AggregateRootClass<TEventFactory>>(
aggregate: TAggregate,
snapshot?: Unknown,
): InstanceType<TAggregate> => {
return aggregate.from(this, snapshot);
},
};
/*
|--------------------------------------------------------------------------------
@@ -341,43 +364,6 @@ export class EventStore<
return makeReducer<TEventFactory, TState>(foldFn, stateFn);
}
/**
* Make a new event reducer based on the events registered with the event store.
*
* @param aggregate - Aggregate class to create instance from.
*
* @example
* ```ts
* class Foo extends AggregateRoot<Event> {
* name: string = "";
*
* static #reducer = makeAggregateReducer(Foo);
*
* static async getById(fooId: string): Promise<Foo | undefined> {
* return eventStore.reduce({
* name: "foo",
* stream: "stream-id",
* reducer: this.#reducer,
* });
* }
*
* with(event) {
* switch (event.type) {
* case "FooCreated": {
* this.name = event.data.name;
* break;
* }
* }
* }
* });
* ```
*/
makeAggregateReducer<TAggregateRoot extends typeof AggregateRoot<TEventFactory>>(
aggregate: TAggregateRoot,
): Reducer<TEventFactory, InstanceType<TAggregateRoot>> {
return makeAggregateReducer<TEventFactory, TAggregateRoot>(aggregate);
}
/**
* Reduce events in the given stream to a entity state.
*
@@ -540,14 +526,9 @@ export class EventStore<
|--------------------------------------------------------------------------------
*/
type EventStoreConfig<
TEventFactory extends EventFactory,
TAggregateFactory extends AggregateFactory<TEventFactory>,
TEventStoreAdapter extends EventStoreAdapter<any>,
> = {
type EventStoreConfig<TEventFactory extends EventFactory, TEventStoreAdapter extends EventStoreAdapter<any>> = {
adapter: TEventStoreAdapter;
events: TEventFactory;
aggregates: TAggregateFactory;
snapshot?: "manual" | "auto";
hooks?: EventStoreHooks<TEventFactory>;
};
@@ -588,4 +569,4 @@ export type EventStoreHooks<TEventFactory extends EventFactory> = Partial<{
onError(error: unknown): Promise<void>;
}>;
export type AnyEventStore = EventStore<any, any, any>;
export type AnyEventStore = EventStore<any, any>;

View File

@@ -1,4 +1,4 @@
import z, { ZodType } from "zod/v4";
import z, { ZodType } from "zod";
import { EventValidationError } from "./errors.ts";
import { makeId } from "./nanoid.ts";

View File

@@ -1,6 +1,7 @@
import type { AggregateRoot } from "../libraries/aggregate.ts";
import type { Unknown } from "../types/common.ts";
import { EventFactory } from "./event-factory.ts";
import type { AnyEventStore } from "./event-store.ts";
/**
* Make an event reducer that produces a aggregate instance from resolved
@@ -11,13 +12,13 @@ import { EventFactory } from "./event-factory.ts";
export function makeAggregateReducer<
TEventFactory extends EventFactory,
TAggregateRoot extends typeof AggregateRoot<TEventFactory>,
>(aggregate: TAggregateRoot): Reducer<TEventFactory, InstanceType<TAggregateRoot>> {
>(store: AnyEventStore, aggregate: TAggregateRoot): Reducer<TEventFactory, InstanceType<TAggregateRoot>> {
return {
from(snapshot: Unknown) {
return aggregate.from(snapshot);
return aggregate.from(store, snapshot);
},
reduce(events: TEventFactory["$events"][number]["$record"][], snapshot?: Unknown) {
const instance = aggregate.from(snapshot);
const instance = aggregate.from(store, snapshot);
for (const event of events) {
instance.with(event);
}

View File

@@ -1,4 +1,4 @@
import { ZodError } from "zod/v4";
import { ZodError } from "zod";
export function toPrettyErrorLines(error: ZodError, padding: number = 0): string[] {
const lines: string[] = [];

1
mod.ts
View File

@@ -1,5 +1,4 @@
export * from "./libraries/aggregate.ts";
export * from "./libraries/aggregate-factory.ts";
export * from "./libraries/errors.ts";
export * from "./libraries/event.ts";
export * from "./libraries/event-factory.ts";

View File

@@ -4,17 +4,17 @@
"mongodb": "6",
"nanoid": "5",
"postgres": "3",
"zod": "3.25"
"zod": "4"
},
"devDependencies": {
"@std/async": "npm:@jsr/std__async@1.0.13",
"@std/assert": "npm:@jsr/std__assert@1.0.13",
"@std/testing": "npm:@jsr/std__testing@1.0.14",
"@std/async": "npm:@jsr/std__async@1",
"@std/assert": "npm:@jsr/std__assert@1",
"@std/testing": "npm:@jsr/std__testing@1",
"@valkyr/testcontainers": "npm:@jsr/valkyr__testcontainers@2",
"eslint": "9.30.1",
"eslint-plugin-simple-import-sort": "12.1.1",
"fake-indexeddb": "6.0.1",
"prettier": "3.6.2",
"typescript-eslint": "8.35.1"
"eslint": "9",
"eslint-plugin-simple-import-sort": "12",
"fake-indexeddb": "6",
"prettier": "3",
"typescript-eslint": "8"
}
}

View File

@@ -6,8 +6,7 @@ import { afterAll, describe } from "@std/testing/bdd";
import { BrowserAdapter } from "../adapters/browser/adapter.ts";
import { EventStore, EventStoreHooks } from "../libraries/event-store.ts";
import { Projector } from "../libraries/projector.ts";
import { aggregates } from "./mocks/aggregates.ts";
import { events, EventStoreFactory } from "./mocks/events.ts";
import { Events, events } from "./mocks/events.ts";
import testAddEvent from "./store/add-event.ts";
import testCreateSnapshot from "./store/create-snapshot.ts";
import testMakeAggregateReducer from "./store/make-aggregate-reducer.ts";
@@ -18,7 +17,7 @@ import testPushManyAggregates from "./store/push-many-aggregates.ts";
import testReduce from "./store/reduce.ts";
import testReplayEvents from "./store/replay-events.ts";
const eventStoreFn = async (options: { hooks?: EventStoreHooks<EventStoreFactory> } = {}) => getEventStore(options);
const eventStoreFn = async (options: { hooks?: EventStoreHooks<Events> } = {}) => getEventStore(options);
/*
|--------------------------------------------------------------------------------
@@ -44,7 +43,6 @@ describe("Adapter > Browser (IndexedDb)", () => {
testReplayEvents(eventStoreFn);
testReduce(eventStoreFn);
testOnceProjection(eventStoreFn);
testPushAggregate(eventStoreFn);
testPushManyAggregates(eventStoreFn);
});
@@ -55,15 +53,14 @@ describe("Adapter > Browser (IndexedDb)", () => {
|--------------------------------------------------------------------------------
*/
function getEventStore({ hooks = {} }: { hooks?: EventStoreHooks<EventStoreFactory> }) {
function getEventStore({ hooks = {} }: { hooks?: EventStoreHooks<Events> }) {
const store = new EventStore({
adapter: new BrowserAdapter("indexeddb"),
events,
aggregates,
hooks,
});
const projector = new Projector<EventStoreFactory>();
const projector = new Projector<Events>();
if (hooks.onEventsInserted === undefined) {
store.onEventsInserted(async (records, { batch }) => {

View File

@@ -5,8 +5,7 @@ import { describe } from "@std/testing/bdd";
import { BrowserAdapter } from "../adapters/browser/adapter.ts";
import { EventStore, EventStoreHooks } from "../libraries/event-store.ts";
import { Projector } from "../libraries/projector.ts";
import { aggregates } from "./mocks/aggregates.ts";
import { events, EventStoreFactory } from "./mocks/events.ts";
import { Events, events } from "./mocks/events.ts";
import testAddEvent from "./store/add-event.ts";
import testCreateSnapshot from "./store/create-snapshot.ts";
import testMakeAggregateReducer from "./store/make-aggregate-reducer.ts";
@@ -17,7 +16,7 @@ import testPushManyAggregates from "./store/push-many-aggregates.ts";
import testReduce from "./store/reduce.ts";
import testReplayEvents from "./store/replay-events.ts";
const eventStoreFn = async (options: { hooks?: EventStoreHooks<EventStoreFactory> } = {}) => getEventStore(options);
const eventStoreFn = async (options: { hooks?: EventStoreHooks<Events> } = {}) => getEventStore(options);
/*
|--------------------------------------------------------------------------------
@@ -33,7 +32,6 @@ describe("Adapter > Browser (memory)", () => {
testReplayEvents(eventStoreFn);
testReduce(eventStoreFn);
testOnceProjection(eventStoreFn);
testPushAggregate(eventStoreFn);
testPushManyAggregates(eventStoreFn);
});
@@ -44,15 +42,14 @@ describe("Adapter > Browser (memory)", () => {
|--------------------------------------------------------------------------------
*/
function getEventStore({ hooks = {} }: { hooks?: EventStoreHooks<EventStoreFactory> }) {
function getEventStore({ hooks = {} }: { hooks?: EventStoreHooks<Events> }) {
const store = new EventStore({
adapter: new BrowserAdapter("memorydb"),
events,
aggregates,
hooks,
});
const projector = new Projector<EventStoreFactory>();
const projector = new Projector<Events>();
if (hooks.onEventsInserted === undefined) {
store.onEventsInserted(async (records, { batch }) => {

View File

@@ -1,13 +1,9 @@
import { AggregateRoot } from "../../libraries/aggregate.ts";
import { AggregateFactory } from "../../libraries/aggregate-factory.ts";
import { makeId } from "../../libraries/nanoid.ts";
import { makeAggregateReducer } from "../../libraries/reducer.ts";
import { EventStoreFactory } from "./events.ts";
import { Events } from "./events.ts";
export class User extends AggregateRoot<EventStoreFactory> {
export class User extends AggregateRoot<Events> {
static override readonly name = "user";
id: string = "";
name: Name = {
given: "",
family: "",
@@ -19,40 +15,12 @@ export class User extends AggregateRoot<EventStoreFactory> {
count: 0,
};
// -------------------------------------------------------------------------
// Factories
// -------------------------------------------------------------------------
static reducer = makeAggregateReducer(User);
static create(name: Name, email: string): User {
const user = new User();
user.push({
type: "user:created",
stream: makeId(),
data: { name, email },
meta: { auditor: "foo" },
});
return user;
}
static async getById(userId: string): Promise<User | undefined> {
return this.$store.reduce({ name: "user", stream: userId, reducer: this.reducer });
}
// -------------------------------------------------------------------------
// Reducer
// -------------------------------------------------------------------------
with(event: EventStoreFactory["$events"][number]["$record"]) {
with(event: Events["$events"][number]["$record"]) {
switch (event.type) {
case "user:created": {
this.id = event.stream;
this.name.given = event.data.name?.given ?? "";
this.name.family = event.data.name?.family ?? "";
this.email = event.data.email;
break;
}
case "user:name:given-set": {
this.name.given = event.data;
break;
@@ -107,11 +75,6 @@ export class User extends AggregateRoot<EventStoreFactory> {
});
}
async snapshot(): Promise<this> {
await this.$store.createSnapshot({ name: "user", stream: this.id, reducer: User.reducer });
return this;
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
@@ -121,8 +84,6 @@ export class User extends AggregateRoot<EventStoreFactory> {
}
}
export const aggregates = new AggregateFactory([User]);
type Name = {
given: string;
family: string;

View File

@@ -1,4 +1,4 @@
import z from "zod/v4";
import z from "zod";
import { event } from "../../libraries/event.ts";
import { EventFactory } from "../../libraries/event-factory.ts";
@@ -32,4 +32,4 @@ export const events = new EventFactory([
event.type("post:removed").meta(auditor),
]);
export type EventStoreFactory = typeof events;
export type Events = typeof events;

View File

@@ -1,7 +1,7 @@
import { makeReducer } from "../../libraries/reducer.ts";
import { EventStoreFactory } from "./events.ts";
import { Events } from "./events.ts";
export const userPostReducer = makeReducer<EventStoreFactory, UserPostState>(
export const userPostReducer = makeReducer<Events, UserPostState>(
(state, event) => {
switch (event.type) {
case "post:created": {

View File

@@ -1,7 +1,7 @@
import { makeReducer } from "../../libraries/reducer.ts";
import { EventStoreFactory } from "./events.ts";
import { Events } from "./events.ts";
export const userReducer = makeReducer<EventStoreFactory, UserState>(
export const userReducer = makeReducer<Events, UserState>(
(state, event) => {
switch (event.type) {
case "user:created": {

View File

@@ -4,8 +4,7 @@ import { MongoTestContainer } from "@valkyr/testcontainers/mongodb";
import { MongoAdapter, register } from "../adapters/mongo/adapter.ts";
import { EventStore, type EventStoreHooks } from "../libraries/event-store.ts";
import { Projector } from "../libraries/projector.ts";
import { aggregates } from "./mocks/aggregates.ts";
import { events, type EventStoreFactory } from "./mocks/events.ts";
import { type Events, events } from "./mocks/events.ts";
import testAddEvent from "./store/add-event.ts";
import testAddManyEvents from "./store/add-many-events.ts";
import testCreateSnapshot from "./store/create-snapshot.ts";
@@ -23,7 +22,7 @@ const DB_NAME = "sandbox";
const container = await MongoTestContainer.start();
const eventStoreFn = async (options: { hooks?: EventStoreHooks<EventStoreFactory> } = {}) => getEventStore(options);
const eventStoreFn = async (options: { hooks?: EventStoreHooks<Events> } = {}) => getEventStore(options);
/*
|--------------------------------------------------------------------------------
@@ -66,7 +65,6 @@ describe("Adapter > MongoDb", () => {
testReplayEvents(eventStoreFn);
testReduce(eventStoreFn);
testOnceProjection(eventStoreFn);
testPushAggregate(eventStoreFn);
testPushManyAggregates(eventStoreFn);
});
@@ -77,15 +75,14 @@ describe("Adapter > MongoDb", () => {
|--------------------------------------------------------------------------------
*/
async function getEventStore({ hooks = {} }: { hooks?: EventStoreHooks<EventStoreFactory> }) {
async function getEventStore({ hooks = {} }: { hooks?: EventStoreHooks<Events> }) {
const store = new EventStore({
adapter: new MongoAdapter(() => container.client, DB_NAME),
events,
aggregates,
hooks,
});
const projector = new Projector<EventStoreFactory>();
const projector = new Projector<Events>();
if (hooks.onEventsInserted === undefined) {
store.onEventsInserted(async (records, { batch }) => {

View File

@@ -6,8 +6,7 @@ import { PostgresAdapter } from "../adapters/postgres/adapter.ts";
import type { PostgresConnection } from "../adapters/postgres/connection.ts";
import { EventStore, type EventStoreHooks } from "../libraries/event-store.ts";
import { Projector } from "../libraries/projector.ts";
import { aggregates } from "./mocks/aggregates.ts";
import { events, EventStoreFactory } from "./mocks/events.ts";
import { Events, events } from "./mocks/events.ts";
import testAddEvent from "./store/add-event.ts";
import testAddManyEvents from "./store/add-many-events.ts";
import testCreateSnapshot from "./store/create-snapshot.ts";
@@ -26,8 +25,7 @@ const DB_NAME = "sandbox";
const container = await PostgresTestContainer.start("postgres:17");
const sql = postgres(container.url(DB_NAME));
const eventStoreFn = async (options: { hooks?: EventStoreHooks<EventStoreFactory> } = {}) =>
getEventStore(sql, options);
const eventStoreFn = async (options: { hooks?: EventStoreHooks<Events> } = {}) => getEventStore(sql, options);
/*
|--------------------------------------------------------------------------------
@@ -103,7 +101,6 @@ describe("Adapter > Postgres", () => {
testReplayEvents(eventStoreFn);
testReduce(eventStoreFn);
testOnceProjection(eventStoreFn);
testPushAggregate(eventStoreFn);
testPushManyAggregates(eventStoreFn);
});
@@ -114,18 +111,14 @@ describe("Adapter > Postgres", () => {
|--------------------------------------------------------------------------------
*/
async function getEventStore(
connection: PostgresConnection,
{ hooks = {} }: { hooks?: EventStoreHooks<EventStoreFactory> },
) {
async function getEventStore(connection: PostgresConnection, { hooks = {} }: { hooks?: EventStoreHooks<Events> }) {
const store = new EventStore({
adapter: new PostgresAdapter(connection, { schema: "event_store" }),
events,
aggregates,
hooks,
});
const projector = new Projector<EventStoreFactory>();
const projector = new Projector<Events>();
if (hooks.onEventsInserted === undefined) {
store.onEventsInserted(async (records, { batch }) => {

View File

@@ -3,10 +3,10 @@ import { it } from "@std/testing/bdd";
import { EventInsertionError, EventValidationError } from "../../libraries/errors.ts";
import { makeId } from "../../libraries/nanoid.ts";
import type { EventStoreFactory } from "../mocks/events.ts";
import type { Events } from "../mocks/events.ts";
import { describe } from "../utilities/describe.ts";
export default describe<EventStoreFactory>(".addEvent", (getEventStore) => {
export default describe<Events>(".addEvent", (getEventStore) => {
it("should throw a 'EventValidationError' when providing bad event data", async () => {
const { store } = await getEventStore();

View File

@@ -3,11 +3,11 @@ import { it } from "@std/testing/bdd";
import { nanoid } from "nanoid";
import { EventValidationError } from "../../mod.ts";
import type { EventStoreFactory } from "../mocks/events.ts";
import type { Events } from "../mocks/events.ts";
import { userReducer } from "../mocks/user-reducer.ts";
import { describe } from "../utilities/describe.ts";
export default describe<EventStoreFactory>(".addSequence", (getEventStore) => {
export default describe<Events>(".addSequence", (getEventStore) => {
it("should insert 'user:created', 'user:name:given-set', and 'user:email-set' in a sequence of events", async () => {
const { store } = await getEventStore();
const stream = nanoid();

View File

@@ -2,11 +2,11 @@ import { assertEquals, assertNotEquals, assertObjectMatch } from "@std/assert";
import { it } from "@std/testing/bdd";
import { nanoid } from "nanoid";
import type { EventStoreFactory } from "../mocks/events.ts";
import type { Events } from "../mocks/events.ts";
import { userReducer } from "../mocks/user-reducer.ts";
import { describe } from "../utilities/describe.ts";
export default describe<EventStoreFactory>(".createSnapshot", (getEventStore) => {
export default describe<Events>(".createSnapshot", (getEventStore) => {
it("should create a new snapshot", async () => {
const { store } = await getEventStore();
const stream = nanoid();

View File

@@ -1,24 +1,26 @@
import { assertEquals } from "@std/assert";
import { it } from "@std/testing/bdd";
import type { EventStoreFactory } from "../mocks/events.ts";
import { User } from "../mocks/aggregates.ts";
import type { Events } from "../mocks/events.ts";
import { describe } from "../utilities/describe.ts";
export default describe<EventStoreFactory>(".makeAggregateReducer", (getEventStore) => {
export default describe<Events>(".makeAggregateReducer", (getEventStore) => {
it("should reduce a user", async () => {
const { store } = await getEventStore();
const userA = await store
.aggregate("user")
.create({ given: "John", family: "Doe" }, "john.doe@fixture.none")
const userA = await store.aggregate
.from(User)
.setGivenName("Jane")
.setFamilyName("Doe")
.setEmail("john.doe@fixture.none", "auditor")
.save();
await userA.snapshot();
await userA.setFamilyName("Smith").setEmail("jane.smith@fixture.none", "system").save();
const userB = await store.aggregate("user").getById(userA.id);
const userB = await store.aggregate.getByStream(User, userA.id);
if (userB === undefined) {
throw new Error("Expected user to exist");
}

View File

@@ -2,10 +2,10 @@ import { assertEquals, assertLess } from "@std/assert";
import { it } from "@std/testing/bdd";
import { RelationPayload } from "../../types/adapter.ts";
import type { EventStoreFactory } from "../mocks/events.ts";
import type { Events } from "../mocks/events.ts";
import { describe } from "../utilities/describe.ts";
export default describe<EventStoreFactory>(".makeEvent", (getEventStore) => {
export default describe<Events>(".makeEvent", (getEventStore) => {
it("should make and performantly batch insert a list of events directly", async () => {
const { store } = await getEventStore();

View File

@@ -3,12 +3,12 @@ import { it } from "@std/testing/bdd";
import { nanoid } from "nanoid";
import { makeId } from "../../libraries/nanoid.ts";
import type { EventStoreFactory } from "../mocks/events.ts";
import type { Events } from "../mocks/events.ts";
import { userPostReducer } from "../mocks/user-posts-reducer.ts";
import { userReducer } from "../mocks/user-reducer.ts";
import { describe } from "../utilities/describe.ts";
export default describe<EventStoreFactory>(".makeReducer", (getEventStore) => {
export default describe<Events>(".makeReducer", (getEventStore) => {
it("should create a 'user' reducer and only reduce filtered events", async () => {
const { store } = await getEventStore();

View File

@@ -2,10 +2,10 @@ import { assertEquals, assertObjectMatch } from "@std/assert";
import { it } from "@std/testing/bdd";
import { makeId } from "../../libraries/nanoid.ts";
import type { EventStoreFactory } from "../mocks/events.ts";
import type { Events } from "../mocks/events.ts";
import { describe } from "../utilities/describe.ts";
export default describe<EventStoreFactory>("projector.once", (getEventStore) => {
export default describe<Events>("projector.once", (getEventStore) => {
it("should handle successfull projection", async () => {
const { store, projector } = await getEventStore();

View File

@@ -2,10 +2,10 @@ import { assertEquals } from "@std/assert";
import { it } from "@std/testing/bdd";
import { nanoid } from "nanoid";
import type { EventStoreFactory } from "../../mocks/events.ts";
import type { Events } from "../../mocks/events.ts";
import { describe } from "../../utilities/describe.ts";
export default describe<EventStoreFactory>("relations", (getEventStore) => {
export default describe<Events>("relations", (getEventStore) => {
it("should create a new relation", async () => {
const { store } = await getEventStore();

View File

@@ -1,36 +1,38 @@
import { assertEquals, assertObjectMatch } from "@std/assert";
import { it } from "@std/testing/bdd";
import type { EventStoreFactory } from "../mocks/events.ts";
import { User } from "../mocks/aggregates.ts";
import type { Events } from "../mocks/events.ts";
import { userReducer } from "../mocks/user-reducer.ts";
import { describe } from "../utilities/describe.ts";
export default describe<EventStoreFactory>(".pushAggregate", (getEventStore) => {
export default describe<Events>(".pushAggregate", (getEventStore) => {
it("should successfully commit pending aggregate events to the event store", async () => {
const { store } = await getEventStore();
const user = store
.aggregate("user")
.create({ given: "Jane", family: "Doe" }, "jane.doe@fixture.none")
const user = store.aggregate
.from(User)
.setGivenName("Jane")
.setFamilyName("Doe")
.setEmail("jane.doe@fixture.none", "admin")
.setGivenName("John")
.setEmail("john.doe@fixture.none", "admin");
assertEquals(user.toPending().length, 3);
assertEquals(user.toPending().length, 5);
await store.pushAggregate(user);
await user.save();
assertEquals(user.toPending().length, 0);
const records = await store.getEventsByStreams([user.id]);
assertEquals(records.length, 3);
assertEquals(records.length, 5);
assertObjectMatch(records[0], {
stream: user.id,
data: { name: { given: "Jane", family: "Doe" }, email: "jane.doe@fixture.none" },
});
assertObjectMatch(records[1], { stream: user.id, data: "John" });
assertObjectMatch(records[2], { stream: user.id, data: "john.doe@fixture.none", meta: { auditor: "admin" } });
assertObjectMatch(records[0], { stream: user.id, data: "Jane" });
assertObjectMatch(records[1], { stream: user.id, data: "Doe" });
assertObjectMatch(records[2], { stream: user.id, data: "jane.doe@fixture.none", meta: { auditor: "admin" } });
assertObjectMatch(records[3], { stream: user.id, data: "John" });
assertObjectMatch(records[4], { stream: user.id, data: "john.doe@fixture.none", meta: { auditor: "admin" } });
const state = await store.reduce({ name: "user", stream: user.id, reducer: userReducer });

View File

@@ -1,50 +1,53 @@
import { assertEquals, assertObjectMatch } from "@std/assert";
import { it } from "@std/testing/bdd";
import type { EventStoreFactory } from "../mocks/events.ts";
import { User } from "../mocks/aggregates.ts";
import type { Events } from "../mocks/events.ts";
import { userReducer } from "../mocks/user-reducer.ts";
import { describe } from "../utilities/describe.ts";
export default describe<EventStoreFactory>(".pushManyAggregates", (getEventStore) => {
export default describe<Events>(".pushManyAggregates", (getEventStore) => {
it("should successfully commit pending aggregates events to the event store", async () => {
const { store } = await getEventStore();
const userA = store
.aggregate("user")
.create({ given: "Jane", family: "Doe" }, "jane.doe@fixture.none")
const userA = store.aggregate
.from(User)
.setGivenName("Jane")
.setFamilyName("Doe")
.setEmail("jane.doe@fixture.none", "admin")
.setGivenName("John")
.setEmail("john.doe@fixture.none", "admin");
const userB = store
.aggregate("user")
.create({ given: "Peter", family: "Doe" }, "peter.doe@fixture.none")
const userB = store.aggregate
.from(User)
.setGivenName("Peter")
.setFamilyName("Doe")
.setEmail("peter.doe@fixture.none", "admin")
.setGivenName("Barry")
.setEmail("barry.doe@fixture.none", "admin");
assertEquals(userA.toPending().length, 3);
assertEquals(userB.toPending().length, 3);
assertEquals(userA.toPending().length, 5);
assertEquals(userB.toPending().length, 5);
await store.pushManyAggregates([userA, userB]);
await store.aggregate.push([userA, userB]);
assertEquals(userA.toPending().length, 0);
assertEquals(userB.toPending().length, 0);
const records = await store.getEventsByStreams([userA.id, userB.id]);
assertEquals(records.length, 6);
assertEquals(records.length, 10);
assertObjectMatch(records[0], {
stream: userA.id,
data: { name: { given: "Jane", family: "Doe" }, email: "jane.doe@fixture.none" },
});
assertObjectMatch(records[1], { stream: userA.id, data: "John" });
assertObjectMatch(records[2], { stream: userA.id, data: "john.doe@fixture.none", meta: { auditor: "admin" } });
assertObjectMatch(records[3], {
stream: userB.id,
data: { name: { given: "Peter", family: "Doe" }, email: "peter.doe@fixture.none" },
});
assertObjectMatch(records[4], { stream: userB.id, data: "Barry" });
assertObjectMatch(records[5], { stream: userB.id, data: "barry.doe@fixture.none", meta: { auditor: "admin" } });
assertObjectMatch(records[0], { stream: userA.id, data: "Jane" });
assertObjectMatch(records[1], { stream: userA.id, data: "Doe" });
assertObjectMatch(records[2], { stream: userA.id, data: "jane.doe@fixture.none", meta: { auditor: "admin" } });
assertObjectMatch(records[3], { stream: userA.id, data: "John" });
assertObjectMatch(records[4], { stream: userA.id, data: "john.doe@fixture.none", meta: { auditor: "admin" } });
assertObjectMatch(records[5], { stream: userB.id, data: "Peter" });
assertObjectMatch(records[6], { stream: userB.id, data: "Doe" });
assertObjectMatch(records[7], { stream: userB.id, data: "peter.doe@fixture.none", meta: { auditor: "admin" } });
assertObjectMatch(records[8], { stream: userB.id, data: "Barry" });
assertObjectMatch(records[9], { stream: userB.id, data: "barry.doe@fixture.none", meta: { auditor: "admin" } });
const stateA = await store.reduce({ name: "user", stream: userA.id, reducer: userReducer });
const stateB = await store.reduce({ name: "user", stream: userB.id, reducer: userReducer });

View File

@@ -2,11 +2,11 @@ import { assertEquals } from "@std/assert";
import { it } from "@std/testing/bdd";
import { nanoid } from "nanoid";
import type { EventStoreFactory } from "../mocks/events.ts";
import type { Events } from "../mocks/events.ts";
import { userReducer } from "../mocks/user-reducer.ts";
import { describe } from "../utilities/describe.ts";
export default describe<EventStoreFactory>(".reduce", (getEventStore) => {
export default describe<Events>(".reduce", (getEventStore) => {
it("should return reduced state", async () => {
const { store } = await getEventStore();
const stream = nanoid();

View File

@@ -2,10 +2,10 @@ import { assertObjectMatch } from "@std/assert";
import { it } from "@std/testing/bdd";
import { nanoid } from "nanoid";
import { EventStoreFactory } from "../mocks/events.ts";
import type { Events } from "../mocks/events.ts";
import { describe } from "../utilities/describe.ts";
export default describe<EventStoreFactory>(".replayEvents", (getEventStore) => {
export default describe<Events>(".replayEvents", (getEventStore) => {
it("should replay events", async () => {
const { store, projector } = await getEventStore();
const stream = nanoid();

View File

@@ -14,6 +14,6 @@ export function describe<TEventFactory extends EventFactory>(
type EventStoreFn<TEventFactory extends EventFactory> = (options?: {
hooks?: EventStoreHooks<TEventFactory>;
}) => Promise<{
store: EventStore<TEventFactory, any, any>;
store: EventStore<TEventFactory, any>;
projector: Projector<TEventFactory>;
}>;