Skip to content

Commit 4dd908f

Browse files
committed
CCM-12934 Add repository for new table
1 parent c96951d commit 4dd908f

6 files changed

Lines changed: 238 additions & 18 deletions

File tree

.vscode/settings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@
1010
"**/Thumbs.db": true,
1111
".github": false,
1212
".vscode": false
13-
}
13+
},
14+
"typescript.tsdk": "node_modules/typescript/lib"
1415
}

internal/datastore/src/__test__/db.ts

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@ export async function setupDynamoDBContainer() {
3030
region: "us-west-2",
3131
endpoint,
3232
lettersTableName: "letters",
33+
letterQueueTableName: "letter-queue",
3334
miTableName: "management-info",
3435
suppliersTableName: "suppliers",
3536
lettersTtlHours: 1,
37+
letterQueueTtlHours: 1,
3638
miTtlHours: 1,
3739
};
3840

@@ -118,6 +120,32 @@ const createSupplierTableCommand = new CreateTableCommand({
118120
],
119121
});
120122

123+
const createLetterQueueTableCommand = new CreateTableCommand({
124+
TableName: "letter-queue",
125+
BillingMode: "PAY_PER_REQUEST",
126+
KeySchema: [
127+
{ AttributeName: "supplierId", KeyType: "HASH" }, // Partition key
128+
{ AttributeName: "letterId", KeyType: "RANGE" }, // Sort key
129+
],
130+
LocalSecondaryIndexes: [
131+
{
132+
IndexName: "timestamp-index",
133+
KeySchema: [
134+
{ AttributeName: "supplierId", KeyType: "HASH" }, // Partition key for LSI
135+
{ AttributeName: "queueTimestamp", KeyType: "RANGE" }, // Sort key for LSI
136+
],
137+
Projection: {
138+
ProjectionType: "ALL",
139+
},
140+
},
141+
],
142+
AttributeDefinitions: [
143+
{ AttributeName: "supplierId", AttributeType: "S" },
144+
{ AttributeName: "letterId", AttributeType: "S" },
145+
{ AttributeName: "queueTimestamp", AttributeType: "S" },
146+
],
147+
});
148+
121149
export async function createTables(context: DBContext) {
122150
const { ddbClient } = context;
123151

@@ -126,26 +154,22 @@ export async function createTables(context: DBContext) {
126154

127155
await ddbClient.send(createMITableCommand);
128156
await ddbClient.send(createSupplierTableCommand);
157+
await ddbClient.send(createLetterQueueTableCommand);
129158
}
130159

131160
export async function deleteTables(context: DBContext) {
132161
const { ddbClient } = context;
133162

134-
await ddbClient.send(
135-
new DeleteTableCommand({
136-
TableName: "letters",
137-
}),
138-
);
139-
140-
await ddbClient.send(
141-
new DeleteTableCommand({
142-
TableName: "management-info",
143-
}),
144-
);
145-
146-
await ddbClient.send(
147-
new DeleteTableCommand({
148-
TableName: "suppliers",
149-
}),
150-
);
163+
for (const tableName of [
164+
"letters",
165+
"management-info",
166+
"suppliers",
167+
"letter-queue",
168+
]) {
169+
await ddbClient.send(
170+
new DeleteTableCommand({
171+
TableName: tableName,
172+
}),
173+
);
174+
}
151175
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { Logger } from "pino";
2+
import {
3+
DBContext,
4+
createTables,
5+
deleteTables,
6+
setupDynamoDBContainer,
7+
} from "./db";
8+
import LetterQueueRepository from "../letter-queue-repository";
9+
import { InsertPendingLetter } from "../types";
10+
import { createTestLogger } from "./logs";
11+
12+
function createLetter(letterId = "letter1"): InsertPendingLetter {
13+
return {
14+
letterId,
15+
supplierId: "supplier1",
16+
specificationId: "specification1",
17+
groupId: "group1",
18+
};
19+
}
20+
21+
// Database tests can take longer, especially with setup and teardown
22+
jest.setTimeout(30_000);
23+
24+
describe("LetterQueueRepository", () => {
25+
let db: DBContext;
26+
let letterQueueRepository: LetterQueueRepository;
27+
let logger: Logger;
28+
29+
beforeAll(async () => {
30+
db = await setupDynamoDBContainer();
31+
});
32+
33+
beforeEach(async () => {
34+
await createTables(db);
35+
({ logger } = createTestLogger());
36+
37+
letterQueueRepository = new LetterQueueRepository(
38+
db.docClient,
39+
logger,
40+
db.config,
41+
);
42+
});
43+
44+
afterEach(async () => {
45+
await deleteTables(db);
46+
jest.useRealTimers();
47+
});
48+
49+
afterAll(async () => {
50+
await db.container.stop();
51+
});
52+
53+
function assertTtl(ttl: number, before: number, after: number) {
54+
const expectedLower = Math.floor(
55+
before / 1000 + 60 * 60 * db.config.letterQueueTtlHours,
56+
);
57+
const expectedUpper = Math.floor(
58+
after / 1000 + 60 * 60 * db.config.lettersTtlHours,
59+
);
60+
expect(ttl).toBeGreaterThanOrEqual(expectedLower);
61+
expect(ttl).toBeLessThanOrEqual(expectedUpper);
62+
}
63+
64+
describe("putLetter", () => {
65+
it("adds a letter to the database", async () => {
66+
const before = Date.now();
67+
68+
const pendingLetter =
69+
await letterQueueRepository.putLetter(createLetter());
70+
71+
const after = Date.now();
72+
73+
const timestampInMillis = new Date(
74+
pendingLetter.queueTimestamp,
75+
).valueOf();
76+
expect(timestampInMillis).toBeGreaterThanOrEqual(before);
77+
expect(timestampInMillis).toBeLessThanOrEqual(after);
78+
assertTtl(pendingLetter.ttl, before, after);
79+
});
80+
81+
it("throws an error when creating a letter which already exists", async () => {
82+
await letterQueueRepository.putLetter(createLetter());
83+
await expect(
84+
letterQueueRepository.putLetter(createLetter()),
85+
).rejects.toThrow(
86+
"Letter with id letter1 already exists for supplier supplier1",
87+
);
88+
});
89+
90+
it("rethrows errors from DynamoDB when creating a letter", async () => {
91+
const misconfiguredRepository = new LetterQueueRepository(
92+
db.docClient,
93+
logger,
94+
{
95+
...db.config,
96+
letterQueueTableName: "nonexistent-table",
97+
},
98+
);
99+
await expect(
100+
misconfiguredRepository.putLetter(createLetter()),
101+
).rejects.toThrow("Cannot do operations on a non-existent table");
102+
});
103+
});
104+
});

internal/datastore/src/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ export type DatastoreConfig = {
22
region: string;
33
endpoint?: string;
44
lettersTableName: string;
5+
letterQueueTableName: string;
56
miTableName: string;
67
suppliersTableName: string;
78
lettersTtlHours: number;
9+
letterQueueTtlHours: number;
810
miTtlHours: number;
911
};
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
2+
import { Logger } from "pino";
3+
import { createHash } from "node:crypto";
4+
import {
5+
InsertPendingLetter,
6+
PendingLetter,
7+
PendingLetterSchema,
8+
} from "./types";
9+
10+
type LetterQueueRepositoryConfig = {
11+
letterQueueTableName: string;
12+
letterQueueTtlHours: number;
13+
};
14+
15+
export function createSha256Hash(
16+
letter: Omit<PendingLetter, "sha256hash" | "ttl">,
17+
): string {
18+
// Use an array so that hash does not depend on insertion order
19+
const dataToHash = JSON.stringify([
20+
letter.groupId,
21+
letter.letterId,
22+
letter.queueTimestamp,
23+
letter.specificationId,
24+
letter.supplierId,
25+
]);
26+
return createHash("sha256").update(dataToHash).digest("hex");
27+
}
28+
29+
export default class LetterQueueRepository {
30+
constructor(
31+
readonly ddbClient: DynamoDBDocumentClient,
32+
readonly log: Logger,
33+
readonly config: LetterQueueRepositoryConfig,
34+
) {}
35+
36+
async putLetter(
37+
insertPendingLetter: InsertPendingLetter,
38+
): Promise<PendingLetter> {
39+
const queueTimestamp = new Date().toISOString();
40+
const letterWithTimestamp = {
41+
...insertPendingLetter,
42+
queueTimestamp, // needs to be an ISO timestamp as Db sorts alphabetically
43+
};
44+
const pendingLetter: PendingLetter = {
45+
...letterWithTimestamp,
46+
sha256hash: createSha256Hash(letterWithTimestamp),
47+
ttl: Math.floor(
48+
Date.now() / 1000 + 60 * 60 * this.config.letterQueueTtlHours,
49+
),
50+
};
51+
try {
52+
await this.ddbClient.send(
53+
new PutCommand({
54+
TableName: this.config.letterQueueTableName,
55+
Item: pendingLetter,
56+
ConditionExpression: "attribute_not_exists(letterId)", // Ensures the supplierId/letterId combination is unique
57+
}),
58+
);
59+
} catch (error) {
60+
if (
61+
error instanceof Error &&
62+
error.name === "ConditionalCheckFailedException"
63+
) {
64+
throw new Error(
65+
`Letter with id ${pendingLetter.letterId} already exists for supplier ${pendingLetter.supplierId}`,
66+
);
67+
}
68+
throw error;
69+
}
70+
return PendingLetterSchema.parse(pendingLetter);
71+
}
72+
}

internal/datastore/src/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,23 @@ export type UpdateLetter = {
7373
reasonText?: string;
7474
};
7575

76+
export const PendingLetterSchema = z.object({
77+
supplierId: idRef(SupplierSchema, "id"),
78+
letterId: idRef(LetterSchema, "id"),
79+
queueTimestamp: z.string().describe("Secondary index SK"),
80+
specificationId: z.string(),
81+
groupId: z.string(),
82+
sha256hash: z.string(),
83+
ttl: z.int(),
84+
});
85+
86+
export type PendingLetter = z.infer<typeof PendingLetterSchema>;
87+
88+
export type InsertPendingLetter = Omit<
89+
PendingLetter,
90+
"ttl" | "queueTimestamp" | "sha256hash"
91+
>;
92+
7693
export const MISchemaBase = z.object({
7794
id: z.string(),
7895
lineItem: z.string(),

0 commit comments

Comments
 (0)