@openhi/constructs 0.0.84 → 0.0.86

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.
package/package.json CHANGED
@@ -1,22 +1,27 @@
1
1
  {
2
2
  "name": "@openhi/constructs",
3
3
  "devDependencies": {
4
- "@swc/core": "^1.15.30",
4
+ "@microsoft/api-extractor": "7.58.7",
5
+ "@swc/core": "^1.15.33",
5
6
  "@swc/jest": "^0.2.39",
6
7
  "@types/express": "5.0.6",
7
8
  "@types/jest": "^30.0.0",
8
- "@types/node": "25.5.2",
9
+ "@types/node": "25.6.0",
10
+ "@types/pg": "^8.20.0",
9
11
  "@typescript-eslint/eslint-plugin": "^8",
10
12
  "@typescript-eslint/parser": "^8",
11
- "aws-cdk-lib": "2.248.0",
13
+ "aws-cdk-lib": "2.252.0",
12
14
  "commit-and-tag-version": "^12",
13
15
  "constructs": "10.6.0",
14
16
  "copyfiles": "^2.4.1",
17
+ "embedded-postgres": "^18.3.0-beta.17",
15
18
  "eslint": "^9",
16
19
  "eslint-config-prettier": "^10.1.8",
17
20
  "eslint-import-resolver-typescript": "^4.4.4",
18
21
  "eslint-plugin-import": "^2.32.0",
22
+ "eslint-plugin-jsdoc": "^62.9.0",
19
23
  "eslint-plugin-prettier": "^5.5.5",
24
+ "eslint-plugin-tsdoc": "^0.5.2",
20
25
  "jest": "^30.3.0",
21
26
  "jest-dynalite": "^3.6.1",
22
27
  "jest-junit": "^16",
@@ -27,15 +32,17 @@
27
32
  "typescript": "^5.9.3"
28
33
  },
29
34
  "peerDependencies": {
30
- "aws-cdk-lib": "2.248.0",
35
+ "aws-cdk-lib": "2.252.0",
31
36
  "constructs": "10.6.0"
32
37
  },
33
38
  "dependencies": {
34
- "@aws-sdk/client-dynamodb": "^3.1033.0",
35
- "@aws-sdk/client-eventbridge": "^3.1033.0",
36
- "@aws-sdk/client-s3": "^3.1033.0",
37
- "@aws-sdk/client-ssm": "^3.1033.0",
38
- "@codedrifters/utils": "^0.0.20",
39
+ "@aws-sdk/client-dynamodb": "^3.1041.0",
40
+ "@aws-sdk/client-eventbridge": "^3.1041.0",
41
+ "@aws-sdk/client-rds-data": "^3.1041.0",
42
+ "@aws-sdk/client-s3": "^3.1041.0",
43
+ "@aws-sdk/client-secrets-manager": "^3.1041.0",
44
+ "@aws-sdk/client-ssm": "^3.1041.0",
45
+ "@codedrifters/utils": "^0.0.22",
39
46
  "@codegenie/serverless-express": "^4.17.1",
40
47
  "@types/aws-lambda": "^8.10.161",
41
48
  "awscdk-appsync-utils": "^0.0.858",
@@ -43,15 +50,16 @@
43
50
  "electrodb": "^3.7.5",
44
51
  "esbuild": "^0.28.0",
45
52
  "express": "^5.2.1",
53
+ "pg": "^8.20.0",
46
54
  "type-fest": "^4",
47
55
  "ulid": "^3.0.2",
48
- "@openhi/config": "0.0.0",
49
- "@openhi/types": "0.0.0"
56
+ "@openhi/types": "0.0.0",
57
+ "@openhi/config": "0.0.0"
50
58
  },
51
59
  "devEngines": {
52
60
  "packageManager": {
53
61
  "name": "pnpm",
54
- "version": "10.33.0",
62
+ "version": "10.33.2",
55
63
  "onFail": "ignore"
56
64
  }
57
65
  },
@@ -60,7 +68,7 @@
60
68
  "publishConfig": {
61
69
  "access": "public"
62
70
  },
63
- "version": "0.0.84",
71
+ "version": "0.0.86",
64
72
  "types": "lib/index.d.ts",
65
73
  "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"pnpm exec projen\".",
66
74
  "scripts": {
@@ -78,6 +86,7 @@
78
86
  "reset": "projen reset",
79
87
  "run-lambda-local": "projen run-lambda-local",
80
88
  "test": "projen test",
89
+ "test:coverage": "projen test:coverage",
81
90
  "test:watch": "projen test:watch",
82
91
  "unbump": "projen unbump",
83
92
  "upgrade": "projen upgrade",
@@ -169,7 +169,7 @@ import {
169
169
  } from "../../data-operations-common";
170
170
 
171
171
  /**
172
- * List ${type}s in a workspace (GSI4). Returns domain result for adapters to map to FHIR Bundle or other formats.
172
+ * List ${type}s in a workspace (GSI1, sharded). Returns domain result for adapters to map to FHIR Bundle or other formats.
173
173
  *
174
174
  * @see sites/www-docs/content/packages/@openhi/constructs/data/shared-data-layer-layout.md
175
175
  */
@@ -180,7 +180,7 @@ export type ${type}ListEntry = ListEntry<${type}>;
180
180
  export type List${pluralLabel(type)}Result = ListResult<${type}>;
181
181
 
182
182
  /**
183
- * Lists all ${type}s in the workspace. Uses GSI4 (Resource Type Index).
183
+ * Lists all ${type}s in the workspace. Uses GSI1 (Unified Sharded List per ADR-011).
184
184
  * Throws on service errors; adapters map to HTTP/GraphQL/Step Function.
185
185
  */
186
186
  export async function ${listOp}(
@@ -134,7 +134,7 @@ import {
134
134
 
135
135
  /**
136
136
  * GET /${type} — search/list: returns a FHIR Bundle (searchset) from the data store.
137
- * Uses GSI4 (Resource Type Index) to list all ${type}s in the workspace without a table scan.
137
+ * Uses GSI1 (Unified Sharded List per ADR-011) to list all ${type}s in the workspace without a table scan.
138
138
  */
139
139
  export async function ${listRoute}(
140
140
  req: Request,
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/components/dynamodb/dynamodb-stream-record.ts","../src/components/dynamodb/data-store-change-events.ts"],"sourcesContent":["import type { AttributeValue } from \"@aws-sdk/client-dynamodb\";\n\n/**\n * Shape of a DynamoDB change record as delivered inside Kinesis (table stream\n * destination) and decoded by the Firehose transform Lambda.\n */\nexport interface DynamoDbStreamKinesisRecord {\n eventName?: string;\n userIdentity?: unknown;\n dynamodb?: {\n Keys?: Record<string, AttributeValue>;\n NewImage?: Record<string, AttributeValue>;\n OldImage?: Record<string, AttributeValue>;\n SequenceNumber?: string;\n ApproximateCreationDateTime?: number;\n };\n}\n\nexport function dynamodbValueToJs(av: AttributeValue): unknown {\n if (av.S !== undefined) {\n return av.S;\n }\n if (av.N !== undefined) {\n return av.N.includes(\".\")\n ? Number.parseFloat(av.N)\n : Number.parseInt(av.N, 10);\n }\n if (av.BOOL !== undefined) {\n return av.BOOL;\n }\n if (av.NULL !== undefined) {\n return null;\n }\n if (av.M !== undefined) {\n return dynamodbImageToPlain(av.M);\n }\n if (av.L !== undefined) {\n return av.L.map((x: AttributeValue) => dynamodbValueToJs(x));\n }\n if (av.SS !== undefined) {\n return av.SS;\n }\n if (av.NS !== undefined) {\n return av.NS.map((n: string) =>\n n.includes(\".\") ? Number.parseFloat(n) : Number.parseInt(n, 10),\n );\n }\n return undefined;\n}\n\nexport function dynamodbImageToPlain(\n image: Record<string, AttributeValue>,\n): Record<string, unknown> {\n const out: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(image)) {\n out[k] = dynamodbValueToJs(v);\n }\n return out;\n}\n","import type { AttributeValue } from \"@aws-sdk/client-dynamodb\";\nimport type { DynamoDbStreamKinesisRecord } from \"./dynamodb-stream-record\";\nimport { dynamodbImageToPlain } from \"./dynamodb-stream-record\";\n\n/**\n * EventBridge envelope constants for data-store CDC (no CDK imports).\n * @see docs/architecture/adr/2026-03-02-01-dynamodb-stream-to-data-event-bus.md\n */\nexport const DATA_STORE_CHANGE_EVENT_SOURCE = \"openhi.data.store\";\n\nexport const DATA_STORE_CHANGE_DETAIL_TYPE = \"FhirCurrentResourceChanged\";\n\n/** AWS PutEvents per-entry detail limit is 256 KiB; stay under for headroom. */\nexport const DATA_STORE_CHANGE_DETAIL_MAX_UTF8_BYTES = 250 * 1024;\n\nconst EXCLUDED_CHANGE_DETAIL_KEYS = new Set([\n \"PK\",\n \"SK\",\n \"GSI1PK\",\n \"GSI1SK\",\n \"GSI2PK\",\n \"GSI2SK\",\n \"GSI3PK\",\n \"GSI3SK\",\n \"GSI4PK\",\n \"GSI4SK\",\n /** Full FHIR JSON may contain PII; never list or ship in the bus payload. */\n \"resource\",\n]);\n\nfunction shallowValueEqual(a: unknown, b: unknown): boolean {\n return JSON.stringify(a) === JSON.stringify(b);\n}\n\nfunction changedNonResourceAttributeNames(\n oldImage: Record<string, unknown> | undefined,\n newImage: Record<string, unknown> | undefined,\n): string[] | undefined {\n if (!oldImage || !newImage) {\n return undefined;\n }\n const names = new Set<string>();\n const keys = new Set([...Object.keys(oldImage), ...Object.keys(newImage)]);\n for (const k of keys) {\n if (EXCLUDED_CHANGE_DETAIL_KEYS.has(k)) {\n continue;\n }\n if (!shallowValueEqual(oldImage[k], newImage[k])) {\n names.add(k);\n }\n }\n return names.size > 0 ? [...names].sort() : undefined;\n}\n\n/** Non-excluded attribute names present on an item (for INSERT / REMOVE). */\nfunction presentMetadataAttributeNames(\n image: Record<string, unknown> | undefined,\n): string[] | undefined {\n if (!image) {\n return undefined;\n }\n const names = Object.keys(image).filter(\n (k) => !EXCLUDED_CHANGE_DETAIL_KEYS.has(k),\n );\n return names.length > 0 ? names.sort() : undefined;\n}\n\nfunction plainImage(\n image: Record<string, AttributeValue> | undefined,\n): Record<string, unknown> | undefined {\n if (!image) {\n return undefined;\n }\n return dynamodbImageToPlain(image);\n}\n\nexport interface FhirCurrentResourceChangeDetail {\n changeType: \"INSERT\" | \"MODIFY\" | \"REMOVE\";\n tenantId: string;\n workspaceId: string;\n resourceType: string;\n resourceId: string;\n resourceVersion: string;\n streamSequenceNumber?: string;\n /** Seconds since UNIX epoch (DynamoDB Streams `ApproximateCreationDateTime`). */\n approximateCreationEpochSec?: number;\n /**\n * MODIFY: attributes whose values differ between old and new images.\n * INSERT / REMOVE: attributes present on the written or removed image (metadata only).\n */\n changedAttributeNames?: string[];\n}\n\nexport function buildFhirCurrentResourceChangeDetail(\n record: DynamoDbStreamKinesisRecord,\n keys: {\n tenantId: string;\n workspaceId: string;\n resourceType: string;\n resourceId: string;\n version: string;\n },\n): FhirCurrentResourceChangeDetail {\n const rawName = record.eventName;\n const changeType =\n rawName === \"INSERT\" || rawName === \"MODIFY\" || rawName === \"REMOVE\"\n ? rawName\n : \"MODIFY\";\n\n const seq = record.dynamodb?.SequenceNumber;\n const approxEpochSec = record.dynamodb?.ApproximateCreationDateTime;\n\n const newPlain = plainImage(\n record.dynamodb?.NewImage as Record<string, AttributeValue> | undefined,\n );\n const oldPlain = plainImage(\n record.dynamodb?.OldImage as Record<string, AttributeValue> | undefined,\n );\n\n let changedAttributeNames: string[] | undefined;\n if (changeType === \"MODIFY\") {\n changedAttributeNames = changedNonResourceAttributeNames(\n oldPlain,\n newPlain,\n );\n } else if (changeType === \"INSERT\") {\n changedAttributeNames = presentMetadataAttributeNames(newPlain);\n } else {\n changedAttributeNames = presentMetadataAttributeNames(oldPlain);\n }\n\n return {\n changeType,\n tenantId: keys.tenantId,\n workspaceId: keys.workspaceId,\n resourceType: keys.resourceType,\n resourceId: keys.resourceId,\n resourceVersion: keys.version,\n ...(typeof seq === \"string\" && seq.length > 0\n ? { streamSequenceNumber: seq }\n : {}),\n ...(typeof approxEpochSec === \"number\" && Number.isFinite(approxEpochSec)\n ? { approximateCreationEpochSec: approxEpochSec }\n : {}),\n ...(changedAttributeNames ? { changedAttributeNames } : {}),\n };\n}\n"],"mappings":"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","names":[]}
@@ -1,227 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Generate one route test file per FHIR type that has rest-api routes.
4
- * Run from constructs package: node scripts/generate-route-tests.mjs
5
- * Ensures routes/data/<folder>/<folder>.test.ts exists for each type.
6
- */
7
- import fs from "node:fs";
8
- import path from "node:path";
9
- import { fileURLToPath } from "node:url";
10
-
11
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
- const ROOT = path.resolve(__dirname, "..");
13
- const ROUTES_DATA = path.join(ROOT, "src/data/rest-api/routes/data");
14
- const OPERATIONS = path.join(ROOT, "src/data/operations/data");
15
-
16
- const dirs = fs.readdirSync(OPERATIONS).filter((d) => {
17
- const p = path.join(OPERATIONS, d);
18
- return fs.statSync(p).isDirectory() && fs.existsSync(path.join(p, `${d}-create-operation.ts`));
19
- });
20
-
21
- const types = [];
22
- for (const dir of dirs) {
23
- const createPath = path.join(OPERATIONS, dir, `${dir}-create-operation.ts`);
24
- const line = fs.readFileSync(createPath, "utf8").split("\n")[0];
25
- const match = line.match(/import type \{ ([^}]+) \}/);
26
- if (!match) continue;
27
- const imports = match[1].split(",").map((s) => s.trim());
28
- const type = imports.find((t) => t !== "Meta");
29
- if (type) types.push({ type, folder: dir });
30
- }
31
- types.sort((a, b) => a.type.localeCompare(b.type));
32
-
33
- function basePathKey(type) {
34
- return type.toUpperCase().replace(/-/g, "");
35
- }
36
-
37
- function pluralRoute(type) {
38
- return type + "s";
39
- }
40
-
41
- function testContent(type, folder) {
42
- const key = basePathKey(type);
43
- const plural = pluralRoute(type);
44
- const createOp = `create${type}Operation`;
45
- const updateOp = `update${type}Operation`;
46
- const listOp = `list${plural}Operation`;
47
- const getByIdOp = `get${type}ByIdOperation`;
48
- const deleteOp = `delete${type}Operation`;
49
- const createRoute = `create${type}Route`;
50
- const updateRoute = `update${type}Route`;
51
- const listRoute = `list${plural}Route`;
52
- const getByIdRoute = `get${type}ByIdRoute`;
53
- const deleteRoute = `delete${type}Route`;
54
-
55
- return `import type { Request, Response } from "express";
56
- import { NotFoundError } from "../../../../errors";
57
- import { ${createRoute} } from "./${folder}-create-route";
58
- import { ${deleteRoute} } from "./${folder}-delete-route";
59
- import { ${getByIdRoute} } from "./${folder}-get-by-id-route";
60
- import { ${listRoute} } from "./${folder}-list-route";
61
- import { ${updateRoute} } from "./${folder}-update-route";
62
- import { create${type}Operation } from "../../../../operations/data/${folder}/${folder}-create-operation";
63
- import { delete${type}Operation } from "../../../../operations/data/${folder}/${folder}-delete-operation";
64
- import { get${type}ByIdOperation } from "../../../../operations/data/${folder}/${folder}-get-by-id-operation";
65
- import { list${plural}Operation } from "../../../../operations/data/${folder}/${folder}-list-operation";
66
- import { update${type}Operation } from "../../../../operations/data/${folder}/${folder}-update-operation";
67
-
68
- jest.mock("../../../../operations/data/${folder}/${folder}-create-operation");
69
- jest.mock("../../../../operations/data/${folder}/${folder}-update-operation");
70
- jest.mock("../../../../operations/data/${folder}/${folder}-list-operation");
71
- jest.mock("../../../../operations/data/${folder}/${folder}-get-by-id-operation");
72
- jest.mock("../../../../operations/data/${folder}/${folder}-delete-operation");
73
-
74
- const defaultContext = {
75
- tenantId: "tenant-1",
76
- workspaceId: "ws-1",
77
- date: "2026-02-26T12:00:00.000Z",
78
- actorId: "actor-1",
79
- actorName: "Test Actor",
80
- };
81
-
82
- function mockRes(): Response {
83
- const res = {} as Response;
84
- res.status = jest.fn().mockReturnThis();
85
- res.json = jest.fn().mockReturnThis();
86
- res.location = jest.fn().mockReturnThis();
87
- res.send = jest.fn().mockReturnThis();
88
- return res;
89
- }
90
-
91
- function mockReq(overrides: Partial<{ params: Record<string, string>; body: unknown }> = {}): Request {
92
- const req = {
93
- openhiContext: defaultContext,
94
- params: {},
95
- body: {},
96
- ...overrides,
97
- } as unknown as Request;
98
- return req;
99
- }
100
-
101
- describe("${type} routes", () => {
102
- beforeEach(() => {
103
- jest.clearAllMocks();
104
- });
105
-
106
- describe("${createRoute}", () => {
107
- it("returns 201 and location when create succeeds", async () => {
108
- const id = "01HXYZ";
109
- (create${type}Operation as jest.Mock).mockResolvedValue({
110
- id,
111
- resource: { resourceType: "${type}", id },
112
- });
113
- const req = mockReq({
114
- body: { resourceType: "${type}" },
115
- });
116
- const res = mockRes();
117
-
118
- await ${createRoute}(req, res);
119
-
120
- expect(create${type}Operation).toHaveBeenCalledWith({
121
- context: defaultContext,
122
- body: expect.objectContaining({ resourceType: "${type}" }),
123
- });
124
- expect(res.status).toHaveBeenCalledWith(201);
125
- expect(res.location).toHaveBeenCalled();
126
- expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ id }));
127
- });
128
- });
129
-
130
- describe("${listRoute}", () => {
131
- it("returns bundle when list succeeds", async () => {
132
- (list${plural}Operation as jest.Mock).mockResolvedValue({
133
- entries: [{ id: "01HXYZ", resource: { resourceType: "${type}", id: "01HXYZ" } }],
134
- });
135
- const req = mockReq();
136
- const res = mockRes();
137
-
138
- await ${listRoute}(req, res);
139
-
140
- expect(list${plural}Operation).toHaveBeenCalledWith({ context: defaultContext });
141
- expect(res.json).toHaveBeenCalledWith(
142
- expect.objectContaining({
143
- resourceType: "Bundle",
144
- type: "searchset",
145
- entry: expect.any(Array),
146
- }),
147
- );
148
- });
149
- });
150
-
151
- describe("${getByIdRoute}", () => {
152
- it("returns 404 when resource not found", async () => {
153
- (get${type}ByIdOperation as jest.Mock).mockRejectedValue(new NotFoundError("not found"));
154
- const req = mockReq({ params: { id: "01HXYZ" } });
155
- const res = mockRes();
156
-
157
- await ${getByIdRoute}(req, res);
158
-
159
- expect(get${type}ByIdOperation).toHaveBeenCalledWith({
160
- context: defaultContext,
161
- id: "01HXYZ",
162
- });
163
- expect(res.status).toHaveBeenCalledWith(404);
164
- });
165
-
166
- it("returns resource when found", async () => {
167
- const resource = { resourceType: "${type}", id: "01HXYZ" };
168
- (get${type}ByIdOperation as jest.Mock).mockResolvedValue({ resource });
169
- const req = mockReq({ params: { id: "01HXYZ" } });
170
- const res = mockRes();
171
-
172
- await ${getByIdRoute}(req, res);
173
-
174
- expect(res.json).toHaveBeenCalledWith(resource);
175
- });
176
- });
177
-
178
- describe("${updateRoute}", () => {
179
- it("returns 200 and resource when update succeeds", async () => {
180
- const resource = { resourceType: "${type}", id: "01HXYZ" };
181
- (update${type}Operation as jest.Mock).mockResolvedValue({ resource });
182
- const req = mockReq({ params: { id: "01HXYZ" }, body: { resourceType: "${type}" } });
183
- const res = mockRes();
184
-
185
- await ${updateRoute}(req, res);
186
-
187
- expect(update${type}Operation).toHaveBeenCalledWith({
188
- context: defaultContext,
189
- id: "01HXYZ",
190
- body: expect.objectContaining({ resourceType: "${type}", id: "01HXYZ" }),
191
- });
192
- expect(res.json).toHaveBeenCalledWith(resource);
193
- });
194
- });
195
-
196
- describe("${deleteRoute}", () => {
197
- it("returns 204 when delete succeeds", async () => {
198
- (delete${type}Operation as jest.Mock).mockResolvedValue(undefined);
199
- const req = mockReq({ params: { id: "01HXYZ" } });
200
- const res = mockRes();
201
-
202
- await ${deleteRoute}(req, res);
203
-
204
- expect(delete${type}Operation).toHaveBeenCalledWith({
205
- context: defaultContext,
206
- id: "01HXYZ",
207
- });
208
- expect(res.status).toHaveBeenCalledWith(204);
209
- expect(res.send).toHaveBeenCalled();
210
- });
211
- });
212
- });
213
- `;
214
- }
215
-
216
- for (const { type, folder } of types) {
217
- const routeDir = path.join(ROUTES_DATA, folder);
218
- const testFile = path.join(routeDir, `${folder}.test.ts`);
219
- if (fs.existsSync(testFile)) {
220
- console.log(`Skip ${type} (test exists)`);
221
- continue;
222
- }
223
- fs.writeFileSync(testFile, testContent(type, folder), "utf8");
224
- console.log(`Generated ${folder}.test.ts for ${type}`);
225
- }
226
-
227
- console.log(`Total: ${types.length} route types; ensured tests for all.`);