Skip to content

Commit b15b3ab

Browse files
committed
feat(ddb-publisher): document local DynamoDB usage and add sample config store
- add a short README for the ddb-publisher CLI - document running the CLI against a local DynamoDB container - add AWS CLI instructions to create the local supplier config table - add a minimal example config store under tests/example-config-store - rename the package script from start to cli for clearer CLI usage - allow localhost DynamoDB endpoints to use default local fake credentials - rename audit result field from blocking to blockingRecords for clarity - update audit and run tests to match the new audit shape - add coverage for local vs non-local endpoint credential handling
1 parent 288f9b2 commit b15b3ab

20 files changed

Lines changed: 266 additions & 34 deletions

File tree

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@
5656
"generate-dependencies": "npm run generate-dependencies --workspaces --if-present",
5757
"lint": "npm run lint --workspaces",
5858
"lint:fix": "npm run lint:fix --workspaces",
59-
"start": "npm run start --workspace frontend",
6059
"test:unit": "npm run test:unit --workspaces",
6160
"typecheck": "npm run typecheck --workspaces --if-present"
6261
},

packages/ddb-publisher/README.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# ddb-publisher CLI
2+
3+
`ddb-publisher` reads supplier config JSON files from a directory, validates them against the event schemas, audits existing records in DynamoDB, then publishes the records into the target table.
4+
5+
## Run the publisher
6+
7+
From this package directory:
8+
9+
```bash
10+
npm run cli -- \
11+
--source ../../tests/example-config-store \
12+
--env draft \
13+
--table supplier-config-draft \
14+
--dry-run
15+
```
16+
17+
## Local DynamoDB
18+
19+
Start DynamoDB Local in Docker:
20+
21+
```bash
22+
docker run --rm -d \
23+
--name supplier-config-ddb-local \
24+
-p 8000:8000 \
25+
amazon/dynamodb-local
26+
```
27+
28+
Create the config table with the AWS CLI:
29+
30+
```bash
31+
AWS_ACCESS_KEY_ID=fakeMyKeyId \
32+
AWS_SECRET_ACCESS_KEY=fakeSecretAccessKey \
33+
AWS_REGION=eu-west-2 \
34+
aws dynamodb create-table \
35+
--endpoint-url http://localhost:8000 \
36+
--table-name supplier-config-draft \
37+
--billing-mode PAY_PER_REQUEST \
38+
--attribute-definitions AttributeName=pk,AttributeType=S AttributeName=sk,AttributeType=S \
39+
--key-schema AttributeName=pk,KeyType=HASH AttributeName=sk,KeyType=RANGE
40+
```
41+
42+
Then publish to the local table:
43+
44+
```bash
45+
SUPPLIER_CONFIG_DDB_ENDPOINT_URL=http://localhost:8000 \
46+
npm run cli -- \
47+
--source ../../tests/example-config-store \
48+
--env draft \
49+
--table supplier-config-draft
50+
```
51+
52+
Stop the local container when you're done:
53+
54+
```bash
55+
docker stop supplier-config-ddb-local
56+
```
57+
58+
## Useful flags
59+
60+
- `--dry-run` validates local config and schemas without AWS calls
61+
- `--force` bypasses the audit safety check
62+
- `--help` shows all options

packages/ddb-publisher/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@
2525
"private": true,
2626
"scripts": {
2727
"bundle:release": "npm run generate-dependencies --workspaces --if-present && node ./scripts/bundle-release.mjs",
28+
"cli": "tsx src/cli.ts",
2829
"lint": "eslint .",
2930
"lint:fix": "eslint . --fix",
30-
"start": "tsx src/cli.ts",
3131
"test:integration": "jest --config jest.integration.config.ts --runInBand",
3232
"test:unit": "jest",
3333
"typecheck": "tsc --noEmit"

packages/ddb-publisher/src/__tests__/audit-branches.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ describe("auditBeforeLoad branch coverage", () => {
2626
localRecords: [],
2727
});
2828

29-
expect(result.blocking).toEqual([]);
29+
expect(result.blockingRecords).toEqual([]);
3030
});
3131

3232
it("should throw when pk/sk are not strings (and include pk/sk values)", async () => {

packages/ddb-publisher/src/__tests__/audit-local-presence-branch.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,6 @@ describe("auditBeforeLoad local.has branch", () => {
3838
localRecords: local,
3939
});
4040

41-
expect(result.blocking).toEqual([]);
41+
expect(result.blockingRecords).toEqual([]);
4242
});
4343
});

packages/ddb-publisher/src/__tests__/audit-status-branch.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ describe("auditBeforeLoad status branch", () => {
1818
localRecords: [],
1919
});
2020

21-
expect(result.blocking).toEqual([
21+
expect(result.blockingRecords).toEqual([
2222
{ pk: "ENTITY#supplier", sk: "ID#1", status: undefined },
2323
]);
2424
});

packages/ddb-publisher/src/__tests__/audit.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ describe("auditBeforeLoad", () => {
5656
localRecords: local,
5757
});
5858

59-
expect(result.blocking).toHaveLength(1);
60-
expect(result.blocking[0]).toMatchObject({
59+
expect(result.blockingRecords).toHaveLength(1);
60+
expect(result.blockingRecords[0]).toMatchObject({
6161
pk: pkForEntity("supplier"),
6262
sk: skForId("2"),
6363
status: "ACTIVE",
@@ -104,7 +104,7 @@ describe("auditBeforeLoad pagination and filtering", () => {
104104
localRecords: local,
105105
});
106106

107-
const actual = result.blocking
107+
const actual = result.blockingRecords
108108
.map((b) => b.sk)
109109
.sort((a, b) => a.localeCompare(b));
110110
const expected = [skForId("2"), skForId("4")].sort((a, b) =>

packages/ddb-publisher/src/__tests__/run-branches.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ describe("runPublisher branch coverage", () => {
8181
fileStore.validateConfigStore.mockReturnValue({ ok: true, issues: [] });
8282

8383
audit.auditBeforeLoad.mockResolvedValue({
84-
blocking: [{ pk: "ENTITY#supplier", sk: "ID#1" }],
84+
blockingRecords: [{ pk: "ENTITY#supplier", sk: "ID#1" }],
8585
});
8686

8787
await expect(

packages/ddb-publisher/src/__tests__/run.test.ts

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ describe("runPublisher", () => {
115115
fileStore.validateConfigStore.mockReturnValue({ ok: true, issues: [] });
116116

117117
audit.auditBeforeLoad.mockResolvedValue({
118-
blocking: [{ pk: "ENTITY#supplier", sk: "ID#1", status: "ACTIVE" }],
118+
blockingRecords: [{ pk: "ENTITY#supplier", sk: "ID#1", status: "ACTIVE" }],
119119
});
120120

121121
await expect(
@@ -140,7 +140,7 @@ describe("runPublisher", () => {
140140
fileStore.validateConfigStore.mockReturnValue({ ok: true, issues: [] });
141141

142142
audit.auditBeforeLoad.mockResolvedValue({
143-
blocking: [{ pk: "ENTITY#supplier", sk: "ID#1", status: "ACTIVE" }],
143+
blockingRecords: [{ pk: "ENTITY#supplier", sk: "ID#1", status: "ACTIVE" }],
144144
});
145145

146146
await runPublisher({
@@ -163,7 +163,7 @@ describe("runPublisher", () => {
163163
fileStore.validateConfigStore.mockReturnValue({ ok: true, issues: [] });
164164

165165
audit.auditBeforeLoad.mockResolvedValue({
166-
blocking: [],
166+
blockingRecords: [],
167167
});
168168

169169
await runPublisher({
@@ -231,7 +231,7 @@ describe("runPublisher", () => {
231231

232232
fileStore.loadConfigStore.mockResolvedValue(store);
233233
fileStore.validateConfigStore.mockReturnValue({ ok: true, issues: [] });
234-
audit.auditBeforeLoad.mockResolvedValue({ blocking: [] });
234+
audit.auditBeforeLoad.mockResolvedValue({ blockingRecords: [] });
235235

236236
process.env.SUPPLIER_CONFIG_DDB_ENDPOINT_URL = "http://127.0.0.1:8000";
237237

@@ -246,6 +246,69 @@ describe("runPublisher", () => {
246246

247247
expect(awsClient.DynamoDBClient).toHaveBeenCalledWith({
248248
endpoint: "http://127.0.0.1:8000",
249+
region: "eu-west-2",
250+
credentials: {
251+
accessKeyId: "fakeMyKeyId",
252+
secretAccessKey: "fakeSecretAccessKey",
253+
},
254+
});
255+
} finally {
256+
delete process.env.SUPPLIER_CONFIG_DDB_ENDPOINT_URL;
257+
}
258+
});
259+
260+
it("should not inject fake credentials for non-local custom endpoints", async () => {
261+
const store: LoadedConfigStore = {
262+
rootPath: "/tmp",
263+
records: [],
264+
};
265+
266+
fileStore.loadConfigStore.mockResolvedValue(store);
267+
fileStore.validateConfigStore.mockReturnValue({ ok: true, issues: [] });
268+
audit.auditBeforeLoad.mockResolvedValue({ blockingRecords: [] });
269+
270+
process.env.SUPPLIER_CONFIG_DDB_ENDPOINT_URL = "https://dynamodb.eu-west-2.amazonaws.com";
271+
272+
try {
273+
await runPublisher({
274+
sourcePath: "/tmp",
275+
env: "draft",
276+
tableName: "tbl",
277+
dryRun: false,
278+
force: false,
279+
});
280+
281+
expect(awsClient.DynamoDBClient).toHaveBeenCalledWith({
282+
endpoint: "https://dynamodb.eu-west-2.amazonaws.com",
283+
});
284+
} finally {
285+
delete process.env.SUPPLIER_CONFIG_DDB_ENDPOINT_URL;
286+
}
287+
});
288+
289+
it("should not inject fake credentials when the custom endpoint is not a valid URL", async () => {
290+
const store: LoadedConfigStore = {
291+
rootPath: "/tmp",
292+
records: [],
293+
};
294+
295+
fileStore.loadConfigStore.mockResolvedValue(store);
296+
fileStore.validateConfigStore.mockReturnValue({ ok: true, issues: [] });
297+
audit.auditBeforeLoad.mockResolvedValue({ blockingRecords: [] });
298+
299+
process.env.SUPPLIER_CONFIG_DDB_ENDPOINT_URL = "not-a-url";
300+
301+
try {
302+
await runPublisher({
303+
sourcePath: "/tmp",
304+
env: "draft",
305+
tableName: "tbl",
306+
dryRun: false,
307+
force: false,
308+
});
309+
310+
expect(awsClient.DynamoDBClient).toHaveBeenCalledWith({
311+
endpoint: "not-a-url",
249312
});
250313
} finally {
251314
delete process.env.SUPPLIER_CONFIG_DDB_ENDPOINT_URL;
Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
import { ScanCommand } from "@aws-sdk/client-dynamodb";
1+
import {ScanCommand} from "@aws-sdk/client-dynamodb";
22
import type {
33
AttributeValue,
44
ScanCommandOutput,
55
} from "@aws-sdk/client-dynamodb";
6-
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
7-
import { z } from "zod";
6+
import {DynamoDBDocumentClient} from "@aws-sdk/lib-dynamodb";
7+
import {z} from "zod";
88

9-
import type { ConfigRecord } from "@supplier-config/file-store";
9+
import type {ConfigRecord} from "@supplier-config/file-store";
1010

11-
import type { AuditRecord, AuditResult } from "../types";
12-
import { pkForEntity, skForId } from "./keys";
11+
import type {AuditRecord, AuditResult} from "../types";
12+
import {pkForEntity, skForId} from "./keys";
1313

1414
function localKeySet(records: ConfigRecord[]): Set<string> {
1515
const set = new Set<string>();
@@ -19,23 +19,35 @@ function localKeySet(records: ConfigRecord[]): Set<string> {
1919
return set;
2020
}
2121

22+
/**
23+
* Schema matching the DDB scan projection used in the audit.
24+
*/
2225
const scannedItemSchema = z.object({
2326
pk: z.string().min(1),
2427
sk: z.string().min(1),
2528
status: z.string().optional(),
2629
});
2730

31+
/**
32+
* Find any records in DynamoDB that would block loading the provided local records
33+
* (i.e. records that exist in DynamoDB with the same pk/sk but are not DISABLED,
34+
* or records that exist in DynamoDB but not in the local set at all).
35+
* @param params
36+
*/
2837
async function auditBeforeLoad(params: {
2938
ddb: DynamoDBDocumentClient;
3039
tableName: string;
40+
41+
/** Local records to load into ddb/tableName */
3142
localRecords: ConfigRecord[];
3243
}): Promise<AuditResult> {
3344
const local = localKeySet(params.localRecords);
3445

35-
const blocking: AuditRecord[] = [];
46+
const blockingRecords: AuditRecord[] = [];
3647

3748
let lastEvaluatedKey: Record<string, AttributeValue> | null = null;
3849

50+
// Scan all records in the table (projecting only keys used for validation)
3951
do {
4052
const page: ScanCommandOutput = await params.ddb.send(
4153
new ScanCommand({
@@ -49,8 +61,8 @@ async function auditBeforeLoad(params: {
4961
);
5062

5163
for (const rawItem of page.Items ?? []) {
64+
// Check basic structure of the item before processing
5265
const parsed = scannedItemSchema.safeParse(rawItem);
53-
5466
if (!parsed.success) {
5567
const raw = rawItem as Record<string, unknown> | null;
5668
const rawPk = raw && "pk" in raw ? String((raw as any).pk) : "<missing>";
@@ -61,20 +73,20 @@ async function auditBeforeLoad(params: {
6173
);
6274
}
6375

64-
const { pk, sk, status } = parsed.data;
65-
76+
// Check for blocking records
77+
const {pk, sk, status} = parsed.data;
6678
const key = `${pk}|${sk}`;
6779
const isDisabled = status === "DISABLED";
6880

6981
if (!isDisabled && !local.has(key)) {
70-
blocking.push({ pk, sk, status });
82+
blockingRecords.push(parsed.data);
7183
}
7284
}
7385

7486
lastEvaluatedKey = page.LastEvaluatedKey ?? null;
7587
} while (lastEvaluatedKey);
7688

77-
return { blocking };
89+
return {blockingRecords};
7890
}
7991

80-
export { auditBeforeLoad };
92+
export {auditBeforeLoad};

0 commit comments

Comments
 (0)