event-store-adapter-js 1.0.2-snapshot.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.
@@ -0,0 +1,156 @@
1
+ import {
2
+ CreateTableCommand,
3
+ CreateTableCommandInput,
4
+ DynamoDBClient,
5
+ } from "@aws-sdk/client-dynamodb";
6
+ import { StartedTestContainer } from "testcontainers";
7
+
8
+ function createDynamoDBClient(startedContainer: StartedTestContainer) {
9
+ const port = startedContainer.getMappedPort(4566);
10
+ console.log(`port = ${port}`);
11
+
12
+ const dynamodbClient: DynamoDBClient = new DynamoDBClient({
13
+ region: "us-west-1",
14
+ endpoint: `http://localhost:${port}`,
15
+ credentials: {
16
+ accessKeyId: "x",
17
+ secretAccessKey: "x",
18
+ },
19
+ // logger: console,
20
+ });
21
+ return dynamodbClient;
22
+ }
23
+
24
+ async function createJournalTable(
25
+ dynamodbClient: DynamoDBClient,
26
+ tableName: string,
27
+ indexName: string,
28
+ ) {
29
+ const request: CreateTableCommandInput = {
30
+ TableName: tableName,
31
+ AttributeDefinitions: [
32
+ {
33
+ AttributeName: "pkey",
34
+ AttributeType: "S",
35
+ },
36
+ {
37
+ AttributeName: "skey",
38
+ AttributeType: "S",
39
+ },
40
+ {
41
+ AttributeName: "aid",
42
+ AttributeType: "S",
43
+ },
44
+ {
45
+ AttributeName: "seq_nr",
46
+ AttributeType: "N",
47
+ },
48
+ ],
49
+ KeySchema: [
50
+ {
51
+ AttributeName: "pkey",
52
+ KeyType: "HASH",
53
+ },
54
+ {
55
+ AttributeName: "skey",
56
+ KeyType: "RANGE",
57
+ },
58
+ ],
59
+ GlobalSecondaryIndexes: [
60
+ {
61
+ IndexName: indexName,
62
+ KeySchema: [
63
+ {
64
+ AttributeName: "aid",
65
+ KeyType: "HASH",
66
+ },
67
+ {
68
+ AttributeName: "seq_nr",
69
+ KeyType: "RANGE",
70
+ },
71
+ ],
72
+ Projection: {
73
+ ProjectionType: "ALL",
74
+ },
75
+ ProvisionedThroughput: {
76
+ ReadCapacityUnits: 10,
77
+ WriteCapacityUnits: 5,
78
+ },
79
+ },
80
+ ],
81
+ ProvisionedThroughput: {
82
+ ReadCapacityUnits: 10,
83
+ WriteCapacityUnits: 5,
84
+ },
85
+ };
86
+
87
+ await dynamodbClient.send(new CreateTableCommand(request));
88
+ }
89
+
90
+ async function createSnapshotTable(
91
+ dynamodbClient: DynamoDBClient,
92
+ tableName: string,
93
+ indexName: string,
94
+ ) {
95
+ const request: CreateTableCommandInput = {
96
+ TableName: tableName,
97
+ AttributeDefinitions: [
98
+ {
99
+ AttributeName: "pkey",
100
+ AttributeType: "S",
101
+ },
102
+ {
103
+ AttributeName: "skey",
104
+ AttributeType: "S",
105
+ },
106
+ {
107
+ AttributeName: "aid",
108
+ AttributeType: "S",
109
+ },
110
+ {
111
+ AttributeName: "seq_nr",
112
+ AttributeType: "N",
113
+ },
114
+ ],
115
+ KeySchema: [
116
+ {
117
+ AttributeName: "pkey",
118
+ KeyType: "HASH",
119
+ },
120
+ {
121
+ AttributeName: "skey",
122
+ KeyType: "RANGE",
123
+ },
124
+ ],
125
+ GlobalSecondaryIndexes: [
126
+ {
127
+ IndexName: indexName,
128
+ KeySchema: [
129
+ {
130
+ AttributeName: "aid",
131
+ KeyType: "HASH",
132
+ },
133
+ {
134
+ AttributeName: "seq_nr",
135
+ KeyType: "RANGE",
136
+ },
137
+ ],
138
+ Projection: {
139
+ ProjectionType: "ALL",
140
+ },
141
+ ProvisionedThroughput: {
142
+ ReadCapacityUnits: 10,
143
+ WriteCapacityUnits: 5,
144
+ },
145
+ },
146
+ ],
147
+ ProvisionedThroughput: {
148
+ ReadCapacityUnits: 10,
149
+ WriteCapacityUnits: 5,
150
+ },
151
+ };
152
+
153
+ await dynamodbClient.send(new CreateTableCommand(request));
154
+ }
155
+
156
+ export { createDynamoDBClient, createJournalTable, createSnapshotTable };
@@ -0,0 +1,62 @@
1
+ import { Event } from "../../types";
2
+ import { convertJSONToUserAccountId, UserAccountId } from "./user-account-id";
3
+
4
+ interface UserAccountEvent extends Event<UserAccountId> {}
5
+
6
+ function convertJSONtoUserAccountEvent(jsonString: string): UserAccountEvent {
7
+ const obj = JSON.parse(jsonString);
8
+ console.log("UserAccountEventFromJSON", obj);
9
+ const aggregateId = convertJSONToUserAccountId(
10
+ JSON.stringify(obj.data.aggregateId),
11
+ );
12
+ switch (obj.type) {
13
+ case "UserAccountCreated":
14
+ return new UserAccountCreated(
15
+ obj.data.id,
16
+ aggregateId,
17
+ obj.data.name,
18
+ obj.data.sequenceNumber,
19
+ obj.data.occurredAt,
20
+ );
21
+ case "UserAccountRenamed":
22
+ return new UserAccountRenamed(
23
+ obj.data.id,
24
+ aggregateId,
25
+ obj.data.name,
26
+ obj.data.sequenceNumber,
27
+ obj.data.occurredAt,
28
+ );
29
+ default:
30
+ throw new Error(`Unknown type: ${obj.type}`);
31
+ }
32
+ }
33
+
34
+ class UserAccountCreated implements UserAccountEvent {
35
+ public readonly isCreated: boolean = true;
36
+
37
+ constructor(
38
+ public readonly id: string,
39
+ public readonly aggregateId: UserAccountId,
40
+ public readonly name: string,
41
+ public readonly sequenceNumber: number,
42
+ public readonly occurredAt: Date,
43
+ ) {}
44
+ }
45
+
46
+ class UserAccountRenamed implements UserAccountEvent {
47
+ public readonly isCreated: boolean = false;
48
+ constructor(
49
+ public readonly id: string,
50
+ public readonly aggregateId: UserAccountId,
51
+ public readonly name: string,
52
+ public readonly sequenceNumber: number,
53
+ public readonly occurredAt: Date,
54
+ ) {}
55
+ }
56
+
57
+ export {
58
+ UserAccountEvent,
59
+ UserAccountCreated,
60
+ UserAccountRenamed,
61
+ convertJSONtoUserAccountEvent,
62
+ };
@@ -0,0 +1,17 @@
1
+ import { AggregateId } from "../../types";
2
+
3
+ class UserAccountId implements AggregateId {
4
+ public readonly typeName = "user-account";
5
+ constructor(public readonly value: string) {}
6
+
7
+ get asString(): string {
8
+ return `${this.typeName}-${this.value}`;
9
+ }
10
+ }
11
+
12
+ function convertJSONToUserAccountId(jsonString: string): UserAccountId {
13
+ const obj = JSON.parse(jsonString);
14
+ return new UserAccountId(obj.value);
15
+ }
16
+
17
+ export { UserAccountId, convertJSONToUserAccountId };
@@ -0,0 +1,119 @@
1
+ import { describe } from "node:test";
2
+ import {
3
+ GenericContainer,
4
+ StartedTestContainer,
5
+ TestContainer,
6
+ Wait,
7
+ } from "testcontainers";
8
+ import { EventStoreForDynamoDB } from "../event-store-for-dynamodb";
9
+ import { UserAccountId } from "./user-account-id";
10
+ import { UserAccount } from "./user-account";
11
+ import { UserAccountEvent } from "./user-account-event";
12
+ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
13
+ import {
14
+ createDynamoDBClient,
15
+ createJournalTable,
16
+ createSnapshotTable,
17
+ } from "./dynamodb-utils";
18
+ import { ulid } from "ulid";
19
+ import { UserAccountRepository } from "./user-account-repository";
20
+
21
+ afterEach(() => {
22
+ jest.useRealTimers();
23
+ });
24
+
25
+ describe("UserAccountRepository", () => {
26
+ const TEST_TIME_FACTOR = parseFloat(process.env.TEST_TIME_FACTOR ?? "1.0");
27
+ const TIMEOUT: number = 10 * 1000 * TEST_TIME_FACTOR;
28
+ console.log("TIMEOUT = ", TIMEOUT);
29
+
30
+ let container: TestContainer;
31
+ let startedContainer: StartedTestContainer;
32
+ let eventStore: EventStoreForDynamoDB<
33
+ UserAccountId,
34
+ UserAccount,
35
+ UserAccountEvent
36
+ >;
37
+
38
+ const JOURNAL_TABLE_NAME = "journal";
39
+ const SNAPSHOT_TABLE_NAME = "snapshot";
40
+ const JOURNAL_AID_INDEX_NAME = "journal-aid-index";
41
+ const SNAPSHOTS_AID_INDEX_NAME = "snapshots-aid-index";
42
+
43
+ function createEventStore(
44
+ dynamodbClient: DynamoDBClient,
45
+ ): EventStoreForDynamoDB<UserAccountId, UserAccount, UserAccountEvent> {
46
+ return new EventStoreForDynamoDB<
47
+ UserAccountId,
48
+ UserAccount,
49
+ UserAccountEvent
50
+ >(
51
+ dynamodbClient,
52
+ JOURNAL_TABLE_NAME,
53
+ SNAPSHOT_TABLE_NAME,
54
+ JOURNAL_AID_INDEX_NAME,
55
+ SNAPSHOTS_AID_INDEX_NAME,
56
+ 32,
57
+ );
58
+ }
59
+
60
+ beforeAll(async () => {
61
+ container = new GenericContainer("localstack/localstack:2.1.0")
62
+ .withEnvironment({
63
+ SERVICES: "dynamodb",
64
+ DEFAULT_REGION: "us-west-1",
65
+ EAGER_SERVICE_LOADING: "1",
66
+ DYNAMODB_SHARED_DB: "1",
67
+ DYNAMODB_IN_MEMORY: "1",
68
+ })
69
+ .withWaitStrategy(Wait.forLogMessage("Ready."))
70
+ .withExposedPorts(4566);
71
+ startedContainer = await container.start();
72
+ const dynamodbClient = createDynamoDBClient(startedContainer);
73
+ await createJournalTable(
74
+ dynamodbClient,
75
+ JOURNAL_TABLE_NAME,
76
+ JOURNAL_AID_INDEX_NAME,
77
+ );
78
+ await createSnapshotTable(
79
+ dynamodbClient,
80
+ SNAPSHOT_TABLE_NAME,
81
+ SNAPSHOTS_AID_INDEX_NAME,
82
+ );
83
+ eventStore = createEventStore(dynamodbClient);
84
+ }, TIMEOUT);
85
+
86
+ afterAll(async () => {
87
+ if (startedContainer !== undefined) {
88
+ await startedContainer.stop();
89
+ }
90
+ }, TIMEOUT);
91
+
92
+ test(
93
+ "storeAndFindById",
94
+ async () => {
95
+ const userAccountRepository = new UserAccountRepository(eventStore);
96
+
97
+ const id = new UserAccountId(ulid());
98
+ const name = "Alice";
99
+ const [userAccount1, created] = UserAccount.create(id, name);
100
+
101
+ await userAccountRepository.storeEventAndSnapshot(created, userAccount1);
102
+
103
+ const [userAccount2, renamed] = userAccount1.rename("Bob");
104
+
105
+ await userAccountRepository.storeEvent(renamed, userAccount2.version);
106
+
107
+ const userAccount3 = await userAccountRepository.findById(id);
108
+ if (userAccount3 === undefined) {
109
+ throw new Error("userAccount3 is undefined");
110
+ }
111
+
112
+ expect(userAccount3.id).toEqual(id);
113
+ expect(userAccount3.name).toEqual("Bob");
114
+ expect(userAccount3.sequenceNumber).toEqual(2);
115
+ expect(userAccount3.version).toEqual(2);
116
+ },
117
+ TIMEOUT,
118
+ );
119
+ });
@@ -0,0 +1,44 @@
1
+ import {
2
+ UserAccountEvent,
3
+ convertJSONtoUserAccountEvent,
4
+ } from "./user-account-event";
5
+ import { UserAccountId } from "./user-account-id";
6
+ import { convertJSONToUserAccount, UserAccount } from "./user-account";
7
+ import { EventStore } from "../../event-store";
8
+
9
+ class UserAccountRepository {
10
+ constructor(
11
+ private readonly eventStore: EventStore<
12
+ UserAccountId,
13
+ UserAccount,
14
+ UserAccountEvent
15
+ >,
16
+ ) {}
17
+
18
+ async storeEvent(event: UserAccountEvent, version: number) {
19
+ await this.eventStore.persistEvent(event, version);
20
+ }
21
+
22
+ async storeEventAndSnapshot(event: UserAccountEvent, snapshot: UserAccount) {
23
+ await this.eventStore.persistEventAndSnapshot(event, snapshot);
24
+ }
25
+
26
+ async findById(id: UserAccountId): Promise<UserAccount | undefined> {
27
+ const snapshot = await this.eventStore.getLatestSnapshotById(
28
+ id,
29
+ convertJSONToUserAccount,
30
+ );
31
+ if (snapshot === undefined) {
32
+ return undefined;
33
+ } else {
34
+ const events = await this.eventStore.getEventsByIdSinceSequenceNumber(
35
+ id,
36
+ snapshot.sequenceNumber + 1,
37
+ convertJSONtoUserAccountEvent,
38
+ );
39
+ return UserAccount.replay(events, snapshot);
40
+ }
41
+ }
42
+ }
43
+
44
+ export { UserAccountRepository };
@@ -0,0 +1,107 @@
1
+ import { Aggregate } from "../../types";
2
+ import { ulid } from "ulid";
3
+ import { convertJSONToUserAccountId, UserAccountId } from "./user-account-id";
4
+ import {
5
+ UserAccountCreated,
6
+ UserAccountEvent,
7
+ UserAccountRenamed,
8
+ } from "./user-account-event";
9
+
10
+ class UserAccount implements Aggregate<UserAccount, UserAccountId> {
11
+ constructor(
12
+ public readonly id: UserAccountId,
13
+ public readonly name: string,
14
+ public readonly sequenceNumber: number,
15
+ public readonly version: number,
16
+ ) {}
17
+
18
+ incrementSequenceNumber(): UserAccount {
19
+ return new UserAccount(
20
+ this.id,
21
+ this.name,
22
+ this.sequenceNumber + 1,
23
+ this.version,
24
+ );
25
+ }
26
+
27
+ withVersion(version: number): UserAccount {
28
+ return new UserAccount(this.id, this.name, this.sequenceNumber, version);
29
+ }
30
+
31
+ updateVersion(version: (value: number) => number): UserAccount {
32
+ return new UserAccount(
33
+ this.id,
34
+ this.name,
35
+ this.sequenceNumber,
36
+ version(this.version),
37
+ );
38
+ }
39
+
40
+ rename(name: string): [UserAccount, UserAccountEvent] {
41
+ const ua = new UserAccount(
42
+ this.id,
43
+ name,
44
+ this.sequenceNumber + 1,
45
+ this.version,
46
+ );
47
+ const eventId = ulid();
48
+ const event = new UserAccountRenamed(
49
+ eventId,
50
+ this.id,
51
+ name,
52
+ ua.sequenceNumber,
53
+ new Date(),
54
+ );
55
+ return [ua, event];
56
+ }
57
+
58
+ public static create(
59
+ id: UserAccountId,
60
+ name: string,
61
+ ): [UserAccount, UserAccountEvent] {
62
+ const ua = new UserAccount(id, name, 0, 1).incrementSequenceNumber();
63
+ const eventId = ulid();
64
+ const event = new UserAccountCreated(
65
+ eventId,
66
+ id,
67
+ name,
68
+ ua.sequenceNumber,
69
+ new Date(),
70
+ );
71
+ return [ua, event];
72
+ }
73
+
74
+ public static replay(
75
+ events: UserAccountEvent[],
76
+ snapshot: UserAccount,
77
+ ): UserAccount {
78
+ let acc = snapshot;
79
+ for (const event of events) {
80
+ acc = acc.applyEvent(event);
81
+ }
82
+ return acc;
83
+ }
84
+
85
+ public applyEvent(event: UserAccountEvent): UserAccount {
86
+ console.log("applyEvent", event);
87
+ if (event instanceof UserAccountRenamed) {
88
+ const [result] = this.rename(event.name);
89
+ return result;
90
+ } else {
91
+ throw new Error("Unknown event type");
92
+ }
93
+ }
94
+ }
95
+
96
+ function convertJSONToUserAccount(jsonString: string): UserAccount {
97
+ const obj = JSON.parse(jsonString);
98
+ const id = convertJSONToUserAccountId(JSON.stringify(obj.data.id));
99
+ return new UserAccount(
100
+ id,
101
+ obj.data.name,
102
+ obj.data.sequenceNumber,
103
+ obj.data.version,
104
+ );
105
+ }
106
+
107
+ export { UserAccount, convertJSONToUserAccount };
package/src/types.ts ADDED
@@ -0,0 +1,54 @@
1
+ interface AggregateId {
2
+ typeName: string;
3
+ value: string;
4
+ asString: string;
5
+ }
6
+
7
+ interface Aggregate<
8
+ This extends Aggregate<This, AID>,
9
+ AID extends AggregateId,
10
+ > {
11
+ id: AID;
12
+ sequenceNumber: number;
13
+ version: number;
14
+ withVersion(version: number): This;
15
+ updateVersion(version: (value: number) => number): This;
16
+ }
17
+
18
+ interface Event<AID extends AggregateId> {
19
+ id: string;
20
+ aggregateId: AID;
21
+ sequenceNumber: number;
22
+ occurredAt: Date;
23
+ isCreated: boolean;
24
+ }
25
+
26
+ interface KeyResolver<AID extends AggregateId> {
27
+ resolvePartitionKey(aggregateId: AID, shardCount: number): string;
28
+
29
+ resolveSortKey(aggregateId: AID, sequenceNumber: number): string;
30
+ }
31
+
32
+ interface EventSerializer<AID extends AggregateId, E extends Event<AID>> {
33
+ serialize(event: E): Uint8Array;
34
+
35
+ deserialize(bytes: Uint8Array, converter: (json: string) => E): E;
36
+ }
37
+
38
+ interface SnapshotSerializer<
39
+ AID extends AggregateId,
40
+ A extends Aggregate<A, AID>,
41
+ > {
42
+ serialize(aggregate: A): Uint8Array;
43
+
44
+ deserialize(bytes: Uint8Array, converter: (json: string) => A): A;
45
+ }
46
+
47
+ export {
48
+ AggregateId,
49
+ Aggregate,
50
+ Event,
51
+ KeyResolver,
52
+ EventSerializer,
53
+ SnapshotSerializer,
54
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "outDir": "./dist",
4
+ "rootDir": "./src",
5
+ "strict": true,
6
+ "module": "CommonJS",
7
+ "target": "es6",
8
+ "moduleResolution": "Node",
9
+ "lib": ["ESNext"],
10
+ "types": ["node", "jest"],
11
+ "skipLibCheck": true,
12
+ "declaration": true
13
+ },
14
+ "include": ["src/**/*.ts"],
15
+ "exclude": ["node_modules"]
16
+ }
package/version ADDED
@@ -0,0 +1 @@
1
+ 1.0.0-SNAPSHOT