@openhi/constructs 0.0.85 → 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.
@@ -47,10 +47,6 @@ var EXCLUDED_CHANGE_DETAIL_KEYS = /* @__PURE__ */ new Set([
47
47
  "GSI1SK",
48
48
  "GSI2PK",
49
49
  "GSI2SK",
50
- "GSI3PK",
51
- "GSI3SK",
52
- "GSI4PK",
53
- "GSI4SK",
54
50
  /** Full FHIR JSON may contain PII; never list or ship in the bus payload. */
55
51
  "resource"
56
52
  ]);
@@ -130,4 +126,4 @@ export {
130
126
  DATA_STORE_CHANGE_DETAIL_MAX_UTF8_BYTES,
131
127
  buildFhirCurrentResourceChangeDetail
132
128
  };
133
- //# sourceMappingURL=chunk-SWSN6GDD.mjs.map
129
+ //# sourceMappingURL=chunk-CEOAGPYY.mjs.map
@@ -0,0 +1 @@
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 /** 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":[]}
@@ -0,0 +1,298 @@
1
+ import {
2
+ DATA_STORE_CHANGE_DETAIL_MAX_UTF8_BYTES,
3
+ DATA_STORE_CHANGE_DETAIL_TYPE,
4
+ DATA_STORE_CHANGE_EVENT_SOURCE,
5
+ buildFhirCurrentResourceChangeDetail,
6
+ dynamodbImageToPlain
7
+ } from "./chunk-CEOAGPYY.mjs";
8
+
9
+ // src/components/dynamodb/firehose-archive-transform.handler.ts
10
+ import { randomUUID } from "crypto";
11
+ import {
12
+ EventBridgeClient,
13
+ PutEventsCommand
14
+ } from "@aws-sdk/client-eventbridge";
15
+ import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
16
+ var CURRENT_SK = "CURRENT";
17
+ var PK_PATTERN = /^TID#(?<tenantId>[^#]+)#WID#(?<workspaceId>[^#]+)#RT#(?<resourceType>[^#]+)#ID#(?<resourceId>.+)$/;
18
+ var AWS_REP_UPDATE_REGION = "aws:rep:updateregion";
19
+ function getDynamoDbStringAttr(image, name) {
20
+ if (!image) {
21
+ return void 0;
22
+ }
23
+ const av = image[name];
24
+ if (typeof av?.S === "string" && av.S.trim() !== "") {
25
+ return av.S.trim();
26
+ }
27
+ return void 0;
28
+ }
29
+ function primaryImageForReplicationCheck(record) {
30
+ if (record.eventName === "REMOVE") {
31
+ return record.dynamodb?.OldImage;
32
+ }
33
+ return record.dynamodb?.NewImage;
34
+ }
35
+ function shouldDropAsGlobalTableReplicationRecord(record, archiveLambdaRegion) {
36
+ const image = primaryImageForReplicationCheck(record);
37
+ const updateRegion = getDynamoDbStringAttr(image, AWS_REP_UPDATE_REGION);
38
+ if (updateRegion && archiveLambdaRegion && updateRegion !== archiveLambdaRegion) {
39
+ return true;
40
+ }
41
+ return isDynamoDbReplicationUserIdentity(record.userIdentity);
42
+ }
43
+ function isDynamoDbReplicationUserIdentity(userIdentity) {
44
+ if (!userIdentity || typeof userIdentity !== "object") {
45
+ return false;
46
+ }
47
+ const ui = userIdentity;
48
+ const principalRaw = ui.principalId ?? ui.PrincipalId;
49
+ const typeRaw = ui.type ?? ui.Type;
50
+ const principal = typeof principalRaw === "string" ? principalRaw.toLowerCase() : "";
51
+ const type = typeof typeRaw === "string" ? typeRaw.toLowerCase() : "";
52
+ if (type === "service" && principal === "dynamodb.amazonaws.com") {
53
+ return false;
54
+ }
55
+ const replicationMarkers = [
56
+ "awsservicerolefordynamodbreplication",
57
+ "replication.dynamodb.amazonaws.com"
58
+ ];
59
+ return replicationMarkers.some((m) => principal.includes(m));
60
+ }
61
+ function parseCurrentResourceKeys(record) {
62
+ const keys = record.dynamodb?.Keys;
63
+ if (!keys) {
64
+ return null;
65
+ }
66
+ const pkAttr = keys.PK?.S;
67
+ const skAttr = keys.SK?.S;
68
+ if (!pkAttr || skAttr !== CURRENT_SK) {
69
+ return null;
70
+ }
71
+ const m = PK_PATTERN.exec(pkAttr);
72
+ if (!m?.groups) {
73
+ return null;
74
+ }
75
+ const { tenantId, workspaceId, resourceType, resourceId } = m.groups;
76
+ const image = record.eventName === "REMOVE" ? record.dynamodb?.OldImage : record.dynamodb?.NewImage;
77
+ if (!image) {
78
+ return null;
79
+ }
80
+ const plain = dynamodbImageToPlain(image);
81
+ const version = typeof plain.vid === "string" ? plain.vid : null;
82
+ if (!version) {
83
+ return null;
84
+ }
85
+ return { tenantId, workspaceId, resourceType, resourceId, version };
86
+ }
87
+ function partitionToken(value) {
88
+ if (!value || value.trim() === "") {
89
+ return "-";
90
+ }
91
+ return value.replace(/[/\\]/g, "_");
92
+ }
93
+ function buildArchivePayload(record, keys) {
94
+ const newImage = record.dynamodb?.NewImage;
95
+ const oldImage = record.dynamodb?.OldImage;
96
+ const resourceImage = record.eventName === "REMOVE" ? oldImage : newImage;
97
+ const resourcePlain = resourceImage ? dynamodbImageToPlain(resourceImage) : {};
98
+ if (typeof resourcePlain.resource === "string") {
99
+ try {
100
+ resourcePlain.resource = JSON.parse(resourcePlain.resource);
101
+ } catch {
102
+ }
103
+ }
104
+ return {
105
+ eventName: record.eventName,
106
+ archivedAt: (/* @__PURE__ */ new Date()).toISOString(),
107
+ tenantId: keys.tenantId,
108
+ workspaceId: keys.workspaceId,
109
+ resourceType: keys.resourceType,
110
+ resourceId: keys.resourceId,
111
+ version: keys.version,
112
+ resource: resourcePlain
113
+ };
114
+ }
115
+ var PUT_EVENTS_BATCH_SIZE = 10;
116
+ var MAX_PUT_EVENTS_ROUNDS = 3;
117
+ var eventBridgeClient;
118
+ function getEventBridgeClient() {
119
+ const bus = process.env.DATA_EVENT_BUS_NAME?.trim();
120
+ if (!bus) {
121
+ return void 0;
122
+ }
123
+ if (!eventBridgeClient) {
124
+ eventBridgeClient = new EventBridgeClient({});
125
+ }
126
+ return eventBridgeClient;
127
+ }
128
+ var s3ClientForDlq;
129
+ function getS3ClientForDlq() {
130
+ const bucket = process.env.DATA_STORE_PUT_EVENTS_DLQ_BUCKET?.trim();
131
+ if (!bucket) {
132
+ return void 0;
133
+ }
134
+ if (!s3ClientForDlq) {
135
+ s3ClientForDlq = new S3Client({});
136
+ }
137
+ return s3ClientForDlq;
138
+ }
139
+ async function writePutEventsFailuresToDlq(payload) {
140
+ const bucket = process.env.DATA_STORE_PUT_EVENTS_DLQ_BUCKET?.trim();
141
+ const client = getS3ClientForDlq();
142
+ if (!bucket || !client) {
143
+ throw new Error(
144
+ `PutEvents exhausted retries but DATA_STORE_PUT_EVENTS_DLQ_BUCKET is not set (${payload.reason})`
145
+ );
146
+ }
147
+ const day = payload.failedAt.slice(0, 10);
148
+ const key = `put-events-failed/${day}/${randomUUID()}.json`;
149
+ await client.send(
150
+ new PutObjectCommand({
151
+ Bucket: bucket,
152
+ Key: key,
153
+ Body: JSON.stringify(payload),
154
+ ContentType: "application/json"
155
+ })
156
+ );
157
+ }
158
+ async function putEventsChunkWithRetriesAndDlq(client, entries) {
159
+ if (entries.length === 0) {
160
+ return;
161
+ }
162
+ let pending = [...entries];
163
+ for (let round = 1; round <= MAX_PUT_EVENTS_ROUNDS; round++) {
164
+ try {
165
+ const out = await client.send(new PutEventsCommand({ Entries: pending }));
166
+ const failed = out.FailedEntryCount ?? 0;
167
+ if (failed === 0) {
168
+ return;
169
+ }
170
+ const nextPending = [];
171
+ out.Entries?.forEach((e, i) => {
172
+ if (e?.ErrorCode && pending[i]) {
173
+ nextPending.push(pending[i]);
174
+ }
175
+ });
176
+ pending = nextPending;
177
+ if (pending.length === 0) {
178
+ return;
179
+ }
180
+ if (round === MAX_PUT_EVENTS_ROUNDS) {
181
+ await writePutEventsFailuresToDlq({
182
+ dlqSchemaVersion: 1,
183
+ failedAt: (/* @__PURE__ */ new Date()).toISOString(),
184
+ reason: "put_events_partial_failure",
185
+ attemptRounds: MAX_PUT_EVENTS_ROUNDS,
186
+ entries: pending,
187
+ putEventsResultEntries: out.Entries
188
+ });
189
+ return;
190
+ }
191
+ } catch (sdkErr) {
192
+ const sdkMessage = sdkErr instanceof Error ? sdkErr.message : String(sdkErr);
193
+ if (round === MAX_PUT_EVENTS_ROUNDS) {
194
+ await writePutEventsFailuresToDlq({
195
+ dlqSchemaVersion: 1,
196
+ failedAt: (/* @__PURE__ */ new Date()).toISOString(),
197
+ reason: "put_events_sdk_error",
198
+ attemptRounds: MAX_PUT_EVENTS_ROUNDS,
199
+ entries: pending,
200
+ sdkError: sdkMessage
201
+ });
202
+ return;
203
+ }
204
+ await new Promise((r) => setTimeout(r, 50 * round));
205
+ }
206
+ }
207
+ }
208
+ async function publishDataStoreChangeEvents(pending) {
209
+ const client = getEventBridgeClient();
210
+ const busName = process.env.DATA_EVENT_BUS_NAME?.trim();
211
+ if (!client || !busName || pending.length === 0) {
212
+ return;
213
+ }
214
+ const entries = [];
215
+ for (const { change, keys } of pending) {
216
+ const detailObj = buildFhirCurrentResourceChangeDetail(change, keys);
217
+ const detail = JSON.stringify(detailObj);
218
+ const detailBytes = Buffer.byteLength(detail, "utf8");
219
+ if (detailBytes > DATA_STORE_CHANGE_DETAIL_MAX_UTF8_BYTES) {
220
+ throw new Error(
221
+ `Event detail is ${detailBytes} bytes (max ${DATA_STORE_CHANGE_DETAIL_MAX_UTF8_BYTES}); oversize strategy deferred per ADR 2026-03-02-01 (${keys.resourceType}/${keys.resourceId}).`
222
+ );
223
+ }
224
+ entries.push({
225
+ Source: DATA_STORE_CHANGE_EVENT_SOURCE,
226
+ DetailType: DATA_STORE_CHANGE_DETAIL_TYPE,
227
+ Detail: detail,
228
+ EventBusName: busName
229
+ });
230
+ }
231
+ for (let i = 0; i < entries.length; i += PUT_EVENTS_BATCH_SIZE) {
232
+ const chunk = entries.slice(i, i + PUT_EVENTS_BATCH_SIZE);
233
+ await putEventsChunkWithRetriesAndDlq(client, chunk);
234
+ }
235
+ }
236
+ async function handler(event) {
237
+ const records = [];
238
+ const archiveLambdaRegion = process.env.AWS_REGION ?? "";
239
+ const pendingPublish = [];
240
+ for (const rec of event.records) {
241
+ try {
242
+ const payload = Buffer.from(rec.data, "base64").toString("utf8");
243
+ const change = JSON.parse(payload);
244
+ if (shouldDropAsGlobalTableReplicationRecord(change, archiveLambdaRegion)) {
245
+ records.push({
246
+ recordId: rec.recordId,
247
+ result: "Dropped",
248
+ data: rec.data
249
+ });
250
+ continue;
251
+ }
252
+ const keys = parseCurrentResourceKeys(change);
253
+ if (!keys) {
254
+ records.push({
255
+ recordId: rec.recordId,
256
+ result: "Dropped",
257
+ data: rec.data
258
+ });
259
+ continue;
260
+ }
261
+ const archive = buildArchivePayload(change, keys);
262
+ const out = Buffer.from(`${JSON.stringify(archive)}
263
+ `).toString(
264
+ "base64"
265
+ );
266
+ pendingPublish.push({ change, keys });
267
+ records.push({
268
+ recordId: rec.recordId,
269
+ result: "Ok",
270
+ data: out,
271
+ metadata: {
272
+ partitionKeys: {
273
+ tenantId: partitionToken(keys.tenantId),
274
+ workspaceId: partitionToken(keys.workspaceId),
275
+ resourceType: partitionToken(keys.resourceType),
276
+ resourceId: partitionToken(keys.resourceId),
277
+ version: partitionToken(keys.version)
278
+ }
279
+ }
280
+ });
281
+ } catch {
282
+ records.push({
283
+ recordId: rec.recordId,
284
+ result: "ProcessingFailed",
285
+ data: rec.data
286
+ });
287
+ }
288
+ }
289
+ await publishDataStoreChangeEvents(pendingPublish);
290
+ return { records };
291
+ }
292
+
293
+ export {
294
+ shouldDropAsGlobalTableReplicationRecord,
295
+ parseCurrentResourceKeys,
296
+ handler
297
+ };
298
+ //# sourceMappingURL=chunk-X5MHU7DA.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/components/dynamodb/firehose-archive-transform.handler.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport type { AttributeValue } from \"@aws-sdk/client-dynamodb\";\nimport {\n EventBridgeClient,\n PutEventsCommand,\n type PutEventsRequestEntry,\n type PutEventsResultEntry,\n} from \"@aws-sdk/client-eventbridge\";\nimport { PutObjectCommand, S3Client } from \"@aws-sdk/client-s3\";\nimport type {\n FirehoseTransformationEvent,\n FirehoseTransformationResult,\n FirehoseTransformationResultRecord,\n} from \"aws-lambda\";\nimport {\n DATA_STORE_CHANGE_DETAIL_MAX_UTF8_BYTES,\n DATA_STORE_CHANGE_DETAIL_TYPE,\n DATA_STORE_CHANGE_EVENT_SOURCE,\n buildFhirCurrentResourceChangeDetail,\n} from \"./data-store-change-events\";\nimport {\n type DynamoDbStreamKinesisRecord,\n dynamodbImageToPlain,\n} from \"./dynamodb-stream-record\";\n\nexport type { DynamoDbStreamKinesisRecord } from \"./dynamodb-stream-record\";\n\n/**\n * Firehose data-transformation handler: filters DynamoDB change records to\n * current FHIR resource items (SK = CURRENT, TID#…#WID#…#RT#…#ID#… PK),\n * writes archive JSON to S3 via Firehose, sets dynamic partition keys per\n * ADR 2026-03-11-02, and publishes de-identified change notifications to the\n * data event bus via PutEvents per ADR 2026-03-02-01, with retries and an S3\n * dead-letter bucket for entries that still fail.\n */\n\nconst CURRENT_SK = \"CURRENT\";\nconst PK_PATTERN =\n /^TID#(?<tenantId>[^#]+)#WID#(?<workspaceId>[^#]+)#RT#(?<resourceType>[^#]+)#ID#(?<resourceId>.+)$/;\n\n/** DynamoDB-managed attribute on global table items (see AWS Global Tables legacy / replication docs). */\nconst AWS_REP_UPDATE_REGION = \"aws:rep:updateregion\";\n\nfunction getDynamoDbStringAttr(\n image: Record<string, AttributeValue> | undefined,\n name: string,\n): string | undefined {\n if (!image) {\n return undefined;\n }\n const av = image[name];\n if (typeof av?.S === \"string\" && av.S.trim() !== \"\") {\n return av.S.trim();\n }\n return undefined;\n}\n\nfunction primaryImageForReplicationCheck(\n record: DynamoDbStreamKinesisRecord,\n): Record<string, AttributeValue> | undefined {\n if (record.eventName === \"REMOVE\") {\n return record.dynamodb?.OldImage;\n }\n return record.dynamodb?.NewImage;\n}\n\n/**\n * Returns true when this stream/Kinesis record should not be archived because it\n * represents a **replica-side application** of a global-table change (the logical\n * write originated in another Region).\n *\n * - If `aws:rep:updateregion` is present on the item image and differs from\n * `archiveLambdaRegion`, the change was replicated into this Region (archive\n * only in the Region that matches `aws:rep:updateregion`).\n * - Otherwise, if `userIdentity` matches the DynamoDB replication service SLR,\n * treat as replication. **Excluded:** TTL deletes (`type` Service and\n * `principalId` `dynamodb.amazonaws.com`) per AWS stream Identity docs.\n *\n * For MREC global tables version 2019.11.21, AWS documents that stream records\n * may not carry distinguishable metadata; the recommended approach is a custom\n * “write region” attribute on items. If neither that attribute nor\n * `aws:rep:updateregion` nor replication `userIdentity` applies, this function\n * returns false (no drop)—duplicate archives are possible if identical pipelines\n * run in every Region without those signals.\n */\nexport function shouldDropAsGlobalTableReplicationRecord(\n record: DynamoDbStreamKinesisRecord,\n archiveLambdaRegion: string,\n): boolean {\n const image = primaryImageForReplicationCheck(record);\n const updateRegion = getDynamoDbStringAttr(image, AWS_REP_UPDATE_REGION);\n if (\n updateRegion &&\n archiveLambdaRegion &&\n updateRegion !== archiveLambdaRegion\n ) {\n return true;\n }\n\n return isDynamoDbReplicationUserIdentity(record.userIdentity);\n}\n\nfunction isDynamoDbReplicationUserIdentity(userIdentity: unknown): boolean {\n if (!userIdentity || typeof userIdentity !== \"object\") {\n return false;\n }\n const ui = userIdentity as Record<string, unknown>;\n const principalRaw = ui.principalId ?? ui.PrincipalId;\n const typeRaw = ui.type ?? ui.Type;\n const principal =\n typeof principalRaw === \"string\" ? principalRaw.toLowerCase() : \"\";\n const type = typeof typeRaw === \"string\" ? typeRaw.toLowerCase() : \"\";\n\n if (type === \"service\" && principal === \"dynamodb.amazonaws.com\") {\n return false;\n }\n\n const replicationMarkers = [\n \"awsservicerolefordynamodbreplication\",\n \"replication.dynamodb.amazonaws.com\",\n ];\n return replicationMarkers.some((m) => principal.includes(m));\n}\n\nexport function parseCurrentResourceKeys(record: DynamoDbStreamKinesisRecord): {\n tenantId: string;\n workspaceId: string;\n resourceType: string;\n resourceId: string;\n version: string;\n} | null {\n const keys = record.dynamodb?.Keys;\n if (!keys) {\n return null;\n }\n const pkAttr = keys.PK?.S;\n const skAttr = keys.SK?.S;\n if (!pkAttr || skAttr !== CURRENT_SK) {\n return null;\n }\n const m = PK_PATTERN.exec(pkAttr);\n if (!m?.groups) {\n return null;\n }\n const { tenantId, workspaceId, resourceType, resourceId } = m.groups;\n const image =\n record.eventName === \"REMOVE\"\n ? record.dynamodb?.OldImage\n : record.dynamodb?.NewImage;\n if (!image) {\n return null;\n }\n const plain = dynamodbImageToPlain(image as Record<string, AttributeValue>);\n const version = typeof plain.vid === \"string\" ? plain.vid : null;\n if (!version) {\n return null;\n }\n return { tenantId, workspaceId, resourceType, resourceId, version };\n}\n\nfunction partitionToken(value: string): string {\n if (!value || value.trim() === \"\") {\n return \"-\";\n }\n return value.replace(/[/\\\\]/g, \"_\");\n}\n\nfunction buildArchivePayload(\n record: DynamoDbStreamKinesisRecord,\n keys: ReturnType<typeof parseCurrentResourceKeys>,\n): Record<string, unknown> {\n const newImage = record.dynamodb?.NewImage;\n const oldImage = record.dynamodb?.OldImage;\n const resourceImage = record.eventName === \"REMOVE\" ? oldImage : newImage;\n const resourcePlain = resourceImage\n ? dynamodbImageToPlain(resourceImage as Record<string, AttributeValue>)\n : {};\n\n if (typeof resourcePlain.resource === \"string\") {\n try {\n resourcePlain.resource = JSON.parse(resourcePlain.resource) as unknown;\n } catch {\n /* keep raw string if not valid JSON */\n }\n }\n\n return {\n eventName: record.eventName,\n archivedAt: new Date().toISOString(),\n tenantId: keys!.tenantId,\n workspaceId: keys!.workspaceId,\n resourceType: keys!.resourceType,\n resourceId: keys!.resourceId,\n version: keys!.version,\n resource: resourcePlain,\n };\n}\n\nconst PUT_EVENTS_BATCH_SIZE = 10;\n\n/** Full PutEvents rounds per chunk (initial attempt + failure-driven retries). */\nconst MAX_PUT_EVENTS_ROUNDS = 3;\n\nlet eventBridgeClient: EventBridgeClient | undefined;\n\nfunction getEventBridgeClient(): EventBridgeClient | undefined {\n const bus = process.env.DATA_EVENT_BUS_NAME?.trim();\n if (!bus) {\n return undefined;\n }\n if (!eventBridgeClient) {\n eventBridgeClient = new EventBridgeClient({});\n }\n return eventBridgeClient;\n}\n\nlet s3ClientForDlq: S3Client | undefined;\n\nfunction getS3ClientForDlq(): S3Client | undefined {\n const bucket = process.env.DATA_STORE_PUT_EVENTS_DLQ_BUCKET?.trim();\n if (!bucket) {\n return undefined;\n }\n if (!s3ClientForDlq) {\n s3ClientForDlq = new S3Client({});\n }\n return s3ClientForDlq;\n}\n\ntype PutEventsEntry = PutEventsRequestEntry;\n\ninterface PutEventsDlqPayload {\n dlqSchemaVersion: 1;\n failedAt: string;\n reason: \"put_events_partial_failure\" | \"put_events_sdk_error\";\n attemptRounds: number;\n entries: PutEventsEntry[];\n putEventsResultEntries?: PutEventsResultEntry[];\n sdkError?: string;\n}\n\nasync function writePutEventsFailuresToDlq(\n payload: PutEventsDlqPayload,\n): Promise<void> {\n const bucket = process.env.DATA_STORE_PUT_EVENTS_DLQ_BUCKET?.trim();\n const client = getS3ClientForDlq();\n if (!bucket || !client) {\n throw new Error(\n `PutEvents exhausted retries but DATA_STORE_PUT_EVENTS_DLQ_BUCKET is not set (${payload.reason})`,\n );\n }\n const day = payload.failedAt.slice(0, 10);\n const key = `put-events-failed/${day}/${randomUUID()}.json`;\n await client.send(\n new PutObjectCommand({\n Bucket: bucket,\n Key: key,\n Body: JSON.stringify(payload),\n ContentType: \"application/json\",\n }),\n );\n}\n\n/**\n * Sends one PutEvents batch (≤10 entries) with up to {@link MAX_PUT_EVENTS_ROUNDS}\n * rounds. After the last round, remaining failures or a final SDK error are\n * written to the DLQ S3 bucket (if configured); DLQ write failure throws.\n */\nasync function putEventsChunkWithRetriesAndDlq(\n client: EventBridgeClient,\n entries: PutEventsEntry[],\n): Promise<void> {\n if (entries.length === 0) {\n return;\n }\n\n let pending = [...entries];\n\n for (let round = 1; round <= MAX_PUT_EVENTS_ROUNDS; round++) {\n try {\n const out = await client.send(new PutEventsCommand({ Entries: pending }));\n const failed = out.FailedEntryCount ?? 0;\n if (failed === 0) {\n return;\n }\n\n const nextPending: PutEventsEntry[] = [];\n out.Entries?.forEach((e: PutEventsResultEntry | undefined, i: number) => {\n if (e?.ErrorCode && pending[i]) {\n nextPending.push(pending[i]!);\n }\n });\n pending = nextPending;\n\n if (pending.length === 0) {\n return;\n }\n\n if (round === MAX_PUT_EVENTS_ROUNDS) {\n await writePutEventsFailuresToDlq({\n dlqSchemaVersion: 1,\n failedAt: new Date().toISOString(),\n reason: \"put_events_partial_failure\",\n attemptRounds: MAX_PUT_EVENTS_ROUNDS,\n entries: pending,\n putEventsResultEntries: out.Entries,\n });\n return;\n }\n } catch (sdkErr) {\n const sdkMessage =\n sdkErr instanceof Error ? sdkErr.message : String(sdkErr);\n if (round === MAX_PUT_EVENTS_ROUNDS) {\n await writePutEventsFailuresToDlq({\n dlqSchemaVersion: 1,\n failedAt: new Date().toISOString(),\n reason: \"put_events_sdk_error\",\n attemptRounds: MAX_PUT_EVENTS_ROUNDS,\n entries: pending,\n sdkError: sdkMessage,\n });\n return;\n }\n await new Promise((r) => setTimeout(r, 50 * round));\n }\n }\n}\n\nasync function publishDataStoreChangeEvents(\n pending: Array<{\n change: DynamoDbStreamKinesisRecord;\n keys: NonNullable<ReturnType<typeof parseCurrentResourceKeys>>;\n }>,\n): Promise<void> {\n const client = getEventBridgeClient();\n const busName = process.env.DATA_EVENT_BUS_NAME?.trim();\n if (!client || !busName || pending.length === 0) {\n return;\n }\n\n const entries: PutEventsEntry[] = [];\n for (const { change, keys } of pending) {\n const detailObj = buildFhirCurrentResourceChangeDetail(change, keys);\n const detail = JSON.stringify(detailObj);\n const detailBytes = Buffer.byteLength(detail, \"utf8\");\n if (detailBytes > DATA_STORE_CHANGE_DETAIL_MAX_UTF8_BYTES) {\n throw new Error(\n `Event detail is ${detailBytes} bytes (max ${DATA_STORE_CHANGE_DETAIL_MAX_UTF8_BYTES}); ` +\n `oversize strategy deferred per ADR 2026-03-02-01 (${keys.resourceType}/${keys.resourceId}).`,\n );\n }\n entries.push({\n Source: DATA_STORE_CHANGE_EVENT_SOURCE,\n DetailType: DATA_STORE_CHANGE_DETAIL_TYPE,\n Detail: detail,\n EventBusName: busName,\n });\n }\n\n for (let i = 0; i < entries.length; i += PUT_EVENTS_BATCH_SIZE) {\n const chunk = entries.slice(i, i + PUT_EVENTS_BATCH_SIZE);\n await putEventsChunkWithRetriesAndDlq(client, chunk);\n }\n}\n\nexport async function handler(\n event: FirehoseTransformationEvent,\n): Promise<FirehoseTransformationResult> {\n const records: FirehoseTransformationResultRecord[] = [];\n const archiveLambdaRegion = process.env.AWS_REGION ?? \"\";\n const pendingPublish: Array<{\n change: DynamoDbStreamKinesisRecord;\n keys: NonNullable<ReturnType<typeof parseCurrentResourceKeys>>;\n }> = [];\n\n for (const rec of event.records) {\n try {\n const payload = Buffer.from(rec.data, \"base64\").toString(\"utf8\");\n const change = JSON.parse(payload) as DynamoDbStreamKinesisRecord;\n\n if (\n shouldDropAsGlobalTableReplicationRecord(change, archiveLambdaRegion)\n ) {\n records.push({\n recordId: rec.recordId,\n result: \"Dropped\",\n data: rec.data,\n });\n continue;\n }\n\n const keys = parseCurrentResourceKeys(change);\n\n if (!keys) {\n records.push({\n recordId: rec.recordId,\n result: \"Dropped\",\n data: rec.data,\n });\n continue;\n }\n\n const archive = buildArchivePayload(change, keys);\n const out = Buffer.from(`${JSON.stringify(archive)}\\n`).toString(\n \"base64\",\n );\n\n pendingPublish.push({ change, keys });\n\n records.push({\n recordId: rec.recordId,\n result: \"Ok\",\n data: out,\n metadata: {\n partitionKeys: {\n tenantId: partitionToken(keys.tenantId),\n workspaceId: partitionToken(keys.workspaceId),\n resourceType: partitionToken(keys.resourceType),\n resourceId: partitionToken(keys.resourceId),\n version: partitionToken(keys.version),\n },\n },\n });\n } catch {\n records.push({\n recordId: rec.recordId,\n result: \"ProcessingFailed\",\n data: rec.data,\n });\n }\n }\n\n await publishDataStoreChangeEvents(pendingPublish);\n\n return { records };\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,55 @@
1
+ import { KinesisStreamEvent, KinesisStreamBatchResponse } from 'aws-lambda';
2
+ import { D as DynamoDbStreamKinesisRecord } from './dynamodb-stream-record-CJtV6a1t.mjs';
3
+ import '@aws-sdk/client-dynamodb';
4
+
5
+ /**
6
+ * Reset module-scoped caches. Test-only helper; do not invoke from production
7
+ * code paths. Exposed so unit and integration tests can run multiple
8
+ * scenarios within a single Jest worker without leaking state across cases.
9
+ */
10
+ declare function __resetReplicationModuleStateForTests(): void;
11
+ interface UpsertParams {
12
+ tenantId: string;
13
+ workspaceId: string;
14
+ resourceType: string;
15
+ resourceId: string;
16
+ version: string;
17
+ lastUpdated: Date;
18
+ resourceJson: string;
19
+ }
20
+ interface SoftDeleteParams {
21
+ tenantId: string;
22
+ workspaceId: string;
23
+ resourceType: string;
24
+ resourceId: string;
25
+ version: string;
26
+ deletedAt: Date;
27
+ }
28
+ type WriteIntent = {
29
+ kind: "upsert";
30
+ params: UpsertParams;
31
+ } | {
32
+ kind: "delete";
33
+ params: SoftDeleteParams;
34
+ } | {
35
+ kind: "drop";
36
+ };
37
+ /**
38
+ * Translate a single Kinesis-wrapped DynamoDB stream record into a write
39
+ * intent against the `resources` table. Returns `drop` for replica-side
40
+ * global-table records, non-CURRENT items, and any record whose resource
41
+ * payload cannot be decoded as JSON.
42
+ */
43
+ declare function buildWriteIntent(change: DynamoDbStreamKinesisRecord, awsRegion: string): WriteIntent;
44
+ interface PgQueryable {
45
+ query: (text: string, values?: ReadonlyArray<unknown>) => Promise<unknown> | unknown;
46
+ }
47
+ /**
48
+ * Execute a write intent against an open Postgres connection or pool. Pure
49
+ * SQL dispatch — extracted from the handler so unit tests can drive it with a
50
+ * mock client and integration tests can drive it with a real pool.
51
+ */
52
+ declare function applyWriteIntent(client: PgQueryable, schemaName: string, intent: WriteIntent): Promise<void>;
53
+ declare function handler(event: KinesisStreamEvent): Promise<KinesisStreamBatchResponse>;
54
+
55
+ export { __resetReplicationModuleStateForTests, applyWriteIntent, buildWriteIntent, handler };
@@ -0,0 +1,55 @@
1
+ import { KinesisStreamEvent, KinesisStreamBatchResponse } from 'aws-lambda';
2
+ import { D as DynamoDbStreamKinesisRecord } from './dynamodb-stream-record-CJtV6a1t.js';
3
+ import '@aws-sdk/client-dynamodb';
4
+
5
+ /**
6
+ * Reset module-scoped caches. Test-only helper; do not invoke from production
7
+ * code paths. Exposed so unit and integration tests can run multiple
8
+ * scenarios within a single Jest worker without leaking state across cases.
9
+ */
10
+ declare function __resetReplicationModuleStateForTests(): void;
11
+ interface UpsertParams {
12
+ tenantId: string;
13
+ workspaceId: string;
14
+ resourceType: string;
15
+ resourceId: string;
16
+ version: string;
17
+ lastUpdated: Date;
18
+ resourceJson: string;
19
+ }
20
+ interface SoftDeleteParams {
21
+ tenantId: string;
22
+ workspaceId: string;
23
+ resourceType: string;
24
+ resourceId: string;
25
+ version: string;
26
+ deletedAt: Date;
27
+ }
28
+ type WriteIntent = {
29
+ kind: "upsert";
30
+ params: UpsertParams;
31
+ } | {
32
+ kind: "delete";
33
+ params: SoftDeleteParams;
34
+ } | {
35
+ kind: "drop";
36
+ };
37
+ /**
38
+ * Translate a single Kinesis-wrapped DynamoDB stream record into a write
39
+ * intent against the `resources` table. Returns `drop` for replica-side
40
+ * global-table records, non-CURRENT items, and any record whose resource
41
+ * payload cannot be decoded as JSON.
42
+ */
43
+ declare function buildWriteIntent(change: DynamoDbStreamKinesisRecord, awsRegion: string): WriteIntent;
44
+ interface PgQueryable {
45
+ query: (text: string, values?: ReadonlyArray<unknown>) => Promise<unknown> | unknown;
46
+ }
47
+ /**
48
+ * Execute a write intent against an open Postgres connection or pool. Pure
49
+ * SQL dispatch — extracted from the handler so unit tests can drive it with a
50
+ * mock client and integration tests can drive it with a real pool.
51
+ */
52
+ declare function applyWriteIntent(client: PgQueryable, schemaName: string, intent: WriteIntent): Promise<void>;
53
+ declare function handler(event: KinesisStreamEvent): Promise<KinesisStreamBatchResponse>;
54
+
55
+ export { __resetReplicationModuleStateForTests, applyWriteIntent, buildWriteIntent, handler };