@openhi/constructs 0.0.176 → 0.0.178
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/lib/{chunk-XJ5SRUGN.mjs → chunk-B6GCDQKX.mjs} +4 -4
- package/lib/{chunk-XJ5SRUGN.mjs.map → chunk-B6GCDQKX.mjs.map} +1 -1
- package/lib/data-store-postgres-replication.handler.js +11 -5
- package/lib/data-store-postgres-replication.handler.js.map +1 -1
- package/lib/data-store-postgres-replication.handler.mjs +9 -3
- package/lib/data-store-postgres-replication.handler.mjs.map +1 -1
- package/lib/rest-api-lambda.handler.js +37 -9
- package/lib/rest-api-lambda.handler.js.map +1 -1
- package/lib/rest-api-lambda.handler.mjs +38 -10
- package/lib/rest-api-lambda.handler.mjs.map +1 -1
- package/package.json +1 -1
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
buildResourceSoftDeleteSql,
|
|
10
10
|
buildResourceUpsertSql,
|
|
11
11
|
ensureSchemaBootstrap
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-B6GCDQKX.mjs";
|
|
13
13
|
import {
|
|
14
14
|
decompressResource
|
|
15
15
|
} from "./chunk-APVVG7BO.mjs";
|
|
@@ -147,10 +147,16 @@ function buildWriteIntent(change, awsRegion) {
|
|
|
147
147
|
if (shouldDropAsGlobalTableReplicationRecord(change, awsRegion)) {
|
|
148
148
|
return { kind: "drop" };
|
|
149
149
|
}
|
|
150
|
-
const
|
|
151
|
-
if (!
|
|
150
|
+
const parsed = parseCurrentResourceKeys(change);
|
|
151
|
+
if (!parsed) {
|
|
152
152
|
return { kind: "drop" };
|
|
153
153
|
}
|
|
154
|
+
const keys = {
|
|
155
|
+
...parsed,
|
|
156
|
+
tenantId: parsed.tenantId.toLowerCase(),
|
|
157
|
+
workspaceId: parsed.workspaceId.toLowerCase(),
|
|
158
|
+
resourceId: parsed.resourceId.toLowerCase()
|
|
159
|
+
};
|
|
154
160
|
const isRemove = change.eventName === "REMOVE";
|
|
155
161
|
const image = isRemove ? change.dynamodb?.OldImage : change.dynamodb?.NewImage;
|
|
156
162
|
if (!image) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/components/postgres/data-store-postgres-replication.handler.ts"],"sourcesContent":["import {\n GetSecretValueCommand,\n SecretsManagerClient,\n} from \"@aws-sdk/client-secrets-manager\";\nimport type {\n KinesisStreamBatchResponse,\n KinesisStreamEvent,\n KinesisStreamRecord,\n} from \"aws-lambda\";\nimport { Pool } from \"pg\";\nimport {\n buildResourceSoftDeleteSql,\n buildResourceUpsertSql,\n ensureSchemaBootstrap,\n} from \"./data-store-postgres-schema\";\nimport { decompressResource } from \"../../lib/compression\";\nimport {\n type DynamoDbStreamKinesisRecord,\n dynamodbImageToPlain,\n} from \"../dynamodb/dynamodb-stream-record\";\nimport {\n parseCurrentResourceKeys,\n shouldDropAsGlobalTableReplicationRecord,\n} from \"../dynamodb/firehose-archive-transform.handler\";\n\n/**\n * Postgres replication consumer for the data store change Kinesis stream\n * (ADR 2026-04-17-01, phase 1). Reads the same stream that feeds the\n * Firehose-to-S3 archive and projects each current FHIR resource into the\n * `resources` JSONB table on the Aurora Serverless v2 cluster provisioned by\n * {@link DataStorePostgresReplica}. This phase implements replication only;\n * no read-side query routing is wired up here.\n */\n\nconst REQUIRED_ENV_VARS = [\n \"OPENHI_PG_HOST\",\n \"OPENHI_PG_PORT\",\n \"OPENHI_PG_DATABASE\",\n \"OPENHI_PG_SCHEMA\",\n] as const;\n\ntype RequiredEnvVar = (typeof REQUIRED_ENV_VARS)[number];\n\ninterface PostgresConnectionConfig {\n host: string;\n port: number;\n database: string;\n schema: string;\n user: string;\n password: string;\n ssl: boolean;\n}\n\ninterface SecretsManagerCredentials {\n username: string;\n password: string;\n}\n\nlet cachedConfig: PostgresConnectionConfig | undefined;\nlet pool: Pool | undefined;\nlet bootstrapPromise: Promise<void> | undefined;\nlet secretsClient: SecretsManagerClient | undefined;\n\nfunction readEnv(name: RequiredEnvVar): string {\n const v = process.env[name]?.trim();\n if (!v) {\n throw new Error(\n `Missing required environment variable for postgres replication: ${name}`,\n );\n }\n return v;\n}\n\nasync function loadConfig(): Promise<PostgresConnectionConfig> {\n if (cachedConfig) {\n return cachedConfig;\n }\n const host = readEnv(\"OPENHI_PG_HOST\");\n const port = Number.parseInt(readEnv(\"OPENHI_PG_PORT\"), 10);\n const database = readEnv(\"OPENHI_PG_DATABASE\");\n const schema = readEnv(\"OPENHI_PG_SCHEMA\");\n\n // Test/integration setups can supply user/password directly via env to skip\n // the Secrets Manager round-trip. Production always goes through the secret.\n const directUser = process.env.OPENHI_PG_USER?.trim();\n const directPassword = process.env.OPENHI_PG_PASSWORD;\n if (directUser && directPassword !== undefined) {\n cachedConfig = {\n host,\n port,\n database,\n schema,\n user: directUser,\n password: directPassword,\n ssl: process.env.OPENHI_PG_SSL?.trim() === \"true\",\n };\n return cachedConfig;\n }\n\n const secretArn = process.env.OPENHI_PG_SECRET_ARN?.trim();\n if (!secretArn) {\n throw new Error(\n \"Either OPENHI_PG_SECRET_ARN or both OPENHI_PG_USER and OPENHI_PG_PASSWORD must be set\",\n );\n }\n if (!secretsClient) {\n secretsClient = new SecretsManagerClient({});\n }\n const out = await secretsClient.send(\n new GetSecretValueCommand({ SecretId: secretArn }),\n );\n if (!out.SecretString) {\n throw new Error(\n `Secret ${secretArn} returned no SecretString; binary secrets are not supported`,\n );\n }\n const parsed = JSON.parse(out.SecretString) as SecretsManagerCredentials;\n cachedConfig = {\n host,\n port,\n database,\n schema,\n user: parsed.username,\n password: parsed.password,\n ssl: true,\n };\n return cachedConfig;\n}\n\nasync function getPool(): Promise<Pool> {\n if (pool) {\n return pool;\n }\n const cfg = await loadConfig();\n pool = new Pool({\n host: cfg.host,\n port: cfg.port,\n database: cfg.database,\n user: cfg.user,\n password: cfg.password,\n ssl: cfg.ssl ? { rejectUnauthorized: false } : false,\n max: 2,\n idleTimeoutMillis: 60_000,\n connectionTimeoutMillis: 10_000,\n });\n return pool;\n}\n\nasync function ensureBootstrapped(): Promise<void> {\n if (!bootstrapPromise) {\n bootstrapPromise = (async () => {\n const cfg = await loadConfig();\n const p = await getPool();\n await ensureSchemaBootstrap(p, cfg.schema);\n })().catch((err) => {\n // Reset so a subsequent invocation retries the bootstrap on a fresh\n // container; otherwise a transient failure during cold start would\n // poison every record forever.\n bootstrapPromise = undefined;\n throw err;\n });\n }\n return bootstrapPromise;\n}\n\n/**\n * Reset module-scoped caches. Test-only helper; do not invoke from production\n * code paths. Exposed so unit and integration tests can run multiple\n * scenarios within a single Jest worker without leaking state across cases.\n */\nexport function __resetReplicationModuleStateForTests(): void {\n cachedConfig = undefined;\n pool = undefined;\n bootstrapPromise = undefined;\n secretsClient = undefined;\n}\n\nfunction decodeKinesisRecord(\n record: KinesisStreamRecord,\n): DynamoDbStreamKinesisRecord {\n const json = Buffer.from(record.kinesis.data, \"base64\").toString(\"utf8\");\n return JSON.parse(json) as DynamoDbStreamKinesisRecord;\n}\n\nfunction deriveLastUpdated(\n resource: unknown,\n approximateCreationEpochMs: number | undefined,\n): Date {\n if (resource && typeof resource === \"object\") {\n const meta = (resource as { meta?: { lastUpdated?: unknown } }).meta;\n const lu = meta?.lastUpdated;\n if (typeof lu === \"string\") {\n const parsed = new Date(lu);\n if (!Number.isNaN(parsed.getTime())) {\n return parsed;\n }\n }\n }\n if (\n typeof approximateCreationEpochMs === \"number\" &&\n Number.isFinite(approximateCreationEpochMs)\n ) {\n // The Kinesis Data Streams for DynamoDB pipe emits\n // `ApproximateCreationDateTime` in **milliseconds** — NOT seconds\n // (despite the DynamoDB Streams API of the same name being in\n // seconds). Pass the value directly to `Date`; multiplying by 1000\n // overshoots into the year-58000+ range.\n return new Date(approximateCreationEpochMs);\n }\n return new Date();\n}\n\ninterface UpsertParams {\n tenantId: string;\n workspaceId: string;\n resourceType: string;\n resourceId: string;\n version: string;\n lastUpdated: Date;\n resourceJson: string;\n}\n\ninterface SoftDeleteParams {\n tenantId: string;\n workspaceId: string;\n resourceType: string;\n resourceId: string;\n version: string;\n deletedAt: Date;\n}\n\ntype WriteIntent =\n | { kind: \"upsert\"; params: UpsertParams }\n | { kind: \"delete\"; params: SoftDeleteParams }\n | { kind: \"drop\" };\n\n/**\n * Translate a single Kinesis-wrapped DynamoDB stream record into a write\n * intent against the `resources` table. Returns `drop` for replica-side\n * global-table records, non-CURRENT items, and any record whose resource\n * payload cannot be decoded as JSON.\n */\nexport function buildWriteIntent(\n change: DynamoDbStreamKinesisRecord,\n awsRegion: string,\n): WriteIntent {\n if (shouldDropAsGlobalTableReplicationRecord(change, awsRegion)) {\n return { kind: \"drop\" };\n }\n const keys = parseCurrentResourceKeys(change);\n if (!keys) {\n return { kind: \"drop\" };\n }\n\n const isRemove = change.eventName === \"REMOVE\";\n const image = isRemove\n ? change.dynamodb?.OldImage\n : change.dynamodb?.NewImage;\n if (!image) {\n return { kind: \"drop\" };\n }\n\n const plain = dynamodbImageToPlain(image);\n const approxEpochMs = change.dynamodb?.ApproximateCreationDateTime;\n\n if (isRemove) {\n return {\n kind: \"delete\",\n params: {\n tenantId: keys.tenantId,\n workspaceId: keys.workspaceId,\n resourceType: keys.resourceType,\n resourceId: keys.resourceId,\n version: keys.version,\n deletedAt: deriveLastUpdated(undefined, approxEpochMs),\n },\n };\n }\n\n if (typeof plain.resource !== \"string\") {\n return { kind: \"drop\" };\n }\n // The DynamoDB `resource` attribute carries a gzip envelope\n // (`{ v, algo, payload }`) when the write went through the data-operations\n // layer. Decompress before parsing so the JSONB column holds the raw FHIR\n // resource — otherwise JSONB path queries (`resource->>'resourceType'`,\n // GIN-anchored search params) see envelope keys (`v`, `algo`, `payload`)\n // instead of FHIR fields. `decompressResource` accepts uncompressed JSON\n // as a pass-through, so this is safe for legacy/raw inputs too.\n let resourceJson: string;\n let resourceObj: unknown;\n try {\n resourceJson = decompressResource(plain.resource);\n resourceObj = JSON.parse(resourceJson);\n } catch {\n return { kind: \"drop\" };\n }\n\n return {\n kind: \"upsert\",\n params: {\n tenantId: keys.tenantId,\n workspaceId: keys.workspaceId,\n resourceType: keys.resourceType,\n resourceId: keys.resourceId,\n version: keys.version,\n lastUpdated: deriveLastUpdated(resourceObj, approxEpochMs),\n resourceJson,\n },\n };\n}\n\ninterface PgQueryable {\n query: (\n text: string,\n values?: ReadonlyArray<unknown>,\n ) => Promise<unknown> | unknown;\n}\n\n/**\n * Execute a write intent against an open Postgres connection or pool. Pure\n * SQL dispatch — extracted from the handler so unit tests can drive it with a\n * mock client and integration tests can drive it with a real pool.\n */\nexport async function applyWriteIntent(\n client: PgQueryable,\n schemaName: string,\n intent: WriteIntent,\n): Promise<void> {\n if (intent.kind === \"drop\") {\n return;\n }\n if (intent.kind === \"upsert\") {\n const p = intent.params;\n await Promise.resolve(\n client.query(buildResourceUpsertSql(schemaName), [\n p.tenantId,\n p.workspaceId,\n p.resourceType,\n p.resourceId,\n p.version,\n p.lastUpdated,\n p.resourceJson,\n ]),\n );\n return;\n }\n const p = intent.params;\n await Promise.resolve(\n client.query(buildResourceSoftDeleteSql(schemaName), [\n p.tenantId,\n p.workspaceId,\n p.resourceType,\n p.resourceId,\n p.version,\n p.deletedAt,\n ]),\n );\n}\n\nexport async function handler(\n event: KinesisStreamEvent,\n): Promise<KinesisStreamBatchResponse> {\n const awsRegion = process.env.AWS_REGION ?? \"\";\n const batchItemFailures: Array<{ itemIdentifier: string }> = [];\n\n await ensureBootstrapped();\n const cfg = await loadConfig();\n const p = await getPool();\n\n for (const record of event.Records) {\n try {\n const change = decodeKinesisRecord(record);\n const intent = buildWriteIntent(change, awsRegion);\n await applyWriteIntent(p, cfg.schema, intent);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(\n \"postgres replication record failed\",\n record.kinesis.sequenceNumber,\n err,\n );\n batchItemFailures.push({\n itemIdentifier: record.kinesis.sequenceNumber,\n });\n }\n }\n\n return { batchItemFailures };\n}\n"],"mappings":"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","names":["p"]}
|
|
1
|
+
{"version":3,"sources":["../src/components/postgres/data-store-postgres-replication.handler.ts"],"sourcesContent":["import {\n GetSecretValueCommand,\n SecretsManagerClient,\n} from \"@aws-sdk/client-secrets-manager\";\nimport type {\n KinesisStreamBatchResponse,\n KinesisStreamEvent,\n KinesisStreamRecord,\n} from \"aws-lambda\";\nimport { Pool } from \"pg\";\nimport {\n buildResourceSoftDeleteSql,\n buildResourceUpsertSql,\n ensureSchemaBootstrap,\n} from \"./data-store-postgres-schema\";\nimport { decompressResource } from \"../../lib/compression\";\nimport {\n type DynamoDbStreamKinesisRecord,\n dynamodbImageToPlain,\n} from \"../dynamodb/dynamodb-stream-record\";\nimport {\n parseCurrentResourceKeys,\n shouldDropAsGlobalTableReplicationRecord,\n} from \"../dynamodb/firehose-archive-transform.handler\";\n\n/**\n * Postgres replication consumer for the data store change Kinesis stream\n * (ADR 2026-04-17-01, phase 1). Reads the same stream that feeds the\n * Firehose-to-S3 archive and projects each current FHIR resource into the\n * `resources` JSONB table on the Aurora Serverless v2 cluster provisioned by\n * {@link DataStorePostgresReplica}. This phase implements replication only;\n * no read-side query routing is wired up here.\n */\n\nconst REQUIRED_ENV_VARS = [\n \"OPENHI_PG_HOST\",\n \"OPENHI_PG_PORT\",\n \"OPENHI_PG_DATABASE\",\n \"OPENHI_PG_SCHEMA\",\n] as const;\n\ntype RequiredEnvVar = (typeof REQUIRED_ENV_VARS)[number];\n\ninterface PostgresConnectionConfig {\n host: string;\n port: number;\n database: string;\n schema: string;\n user: string;\n password: string;\n ssl: boolean;\n}\n\ninterface SecretsManagerCredentials {\n username: string;\n password: string;\n}\n\nlet cachedConfig: PostgresConnectionConfig | undefined;\nlet pool: Pool | undefined;\nlet bootstrapPromise: Promise<void> | undefined;\nlet secretsClient: SecretsManagerClient | undefined;\n\nfunction readEnv(name: RequiredEnvVar): string {\n const v = process.env[name]?.trim();\n if (!v) {\n throw new Error(\n `Missing required environment variable for postgres replication: ${name}`,\n );\n }\n return v;\n}\n\nasync function loadConfig(): Promise<PostgresConnectionConfig> {\n if (cachedConfig) {\n return cachedConfig;\n }\n const host = readEnv(\"OPENHI_PG_HOST\");\n const port = Number.parseInt(readEnv(\"OPENHI_PG_PORT\"), 10);\n const database = readEnv(\"OPENHI_PG_DATABASE\");\n const schema = readEnv(\"OPENHI_PG_SCHEMA\");\n\n // Test/integration setups can supply user/password directly via env to skip\n // the Secrets Manager round-trip. Production always goes through the secret.\n const directUser = process.env.OPENHI_PG_USER?.trim();\n const directPassword = process.env.OPENHI_PG_PASSWORD;\n if (directUser && directPassword !== undefined) {\n cachedConfig = {\n host,\n port,\n database,\n schema,\n user: directUser,\n password: directPassword,\n ssl: process.env.OPENHI_PG_SSL?.trim() === \"true\",\n };\n return cachedConfig;\n }\n\n const secretArn = process.env.OPENHI_PG_SECRET_ARN?.trim();\n if (!secretArn) {\n throw new Error(\n \"Either OPENHI_PG_SECRET_ARN or both OPENHI_PG_USER and OPENHI_PG_PASSWORD must be set\",\n );\n }\n if (!secretsClient) {\n secretsClient = new SecretsManagerClient({});\n }\n const out = await secretsClient.send(\n new GetSecretValueCommand({ SecretId: secretArn }),\n );\n if (!out.SecretString) {\n throw new Error(\n `Secret ${secretArn} returned no SecretString; binary secrets are not supported`,\n );\n }\n const parsed = JSON.parse(out.SecretString) as SecretsManagerCredentials;\n cachedConfig = {\n host,\n port,\n database,\n schema,\n user: parsed.username,\n password: parsed.password,\n ssl: true,\n };\n return cachedConfig;\n}\n\nasync function getPool(): Promise<Pool> {\n if (pool) {\n return pool;\n }\n const cfg = await loadConfig();\n pool = new Pool({\n host: cfg.host,\n port: cfg.port,\n database: cfg.database,\n user: cfg.user,\n password: cfg.password,\n ssl: cfg.ssl ? { rejectUnauthorized: false } : false,\n max: 2,\n idleTimeoutMillis: 60_000,\n connectionTimeoutMillis: 10_000,\n });\n return pool;\n}\n\nasync function ensureBootstrapped(): Promise<void> {\n if (!bootstrapPromise) {\n bootstrapPromise = (async () => {\n const cfg = await loadConfig();\n const p = await getPool();\n await ensureSchemaBootstrap(p, cfg.schema);\n })().catch((err) => {\n // Reset so a subsequent invocation retries the bootstrap on a fresh\n // container; otherwise a transient failure during cold start would\n // poison every record forever.\n bootstrapPromise = undefined;\n throw err;\n });\n }\n return bootstrapPromise;\n}\n\n/**\n * Reset module-scoped caches. Test-only helper; do not invoke from production\n * code paths. Exposed so unit and integration tests can run multiple\n * scenarios within a single Jest worker without leaking state across cases.\n */\nexport function __resetReplicationModuleStateForTests(): void {\n cachedConfig = undefined;\n pool = undefined;\n bootstrapPromise = undefined;\n secretsClient = undefined;\n}\n\nfunction decodeKinesisRecord(\n record: KinesisStreamRecord,\n): DynamoDbStreamKinesisRecord {\n const json = Buffer.from(record.kinesis.data, \"base64\").toString(\"utf8\");\n return JSON.parse(json) as DynamoDbStreamKinesisRecord;\n}\n\nfunction deriveLastUpdated(\n resource: unknown,\n approximateCreationEpochMs: number | undefined,\n): Date {\n if (resource && typeof resource === \"object\") {\n const meta = (resource as { meta?: { lastUpdated?: unknown } }).meta;\n const lu = meta?.lastUpdated;\n if (typeof lu === \"string\") {\n const parsed = new Date(lu);\n if (!Number.isNaN(parsed.getTime())) {\n return parsed;\n }\n }\n }\n if (\n typeof approximateCreationEpochMs === \"number\" &&\n Number.isFinite(approximateCreationEpochMs)\n ) {\n // The Kinesis Data Streams for DynamoDB pipe emits\n // `ApproximateCreationDateTime` in **milliseconds** — NOT seconds\n // (despite the DynamoDB Streams API of the same name being in\n // seconds). Pass the value directly to `Date`; multiplying by 1000\n // overshoots into the year-58000+ range.\n return new Date(approximateCreationEpochMs);\n }\n return new Date();\n}\n\ninterface UpsertParams {\n tenantId: string;\n workspaceId: string;\n resourceType: string;\n resourceId: string;\n version: string;\n lastUpdated: Date;\n resourceJson: string;\n}\n\ninterface SoftDeleteParams {\n tenantId: string;\n workspaceId: string;\n resourceType: string;\n resourceId: string;\n version: string;\n deletedAt: Date;\n}\n\ntype WriteIntent =\n | { kind: \"upsert\"; params: UpsertParams }\n | { kind: \"delete\"; params: SoftDeleteParams }\n | { kind: \"drop\" };\n\n/**\n * Translate a single Kinesis-wrapped DynamoDB stream record into a write\n * intent against the `resources` table. Returns `drop` for replica-side\n * global-table records, non-CURRENT items, and any record whose resource\n * payload cannot be decoded as JSON.\n */\nexport function buildWriteIntent(\n change: DynamoDbStreamKinesisRecord,\n awsRegion: string,\n): WriteIntent {\n if (shouldDropAsGlobalTableReplicationRecord(change, awsRegion)) {\n return { kind: \"drop\" };\n }\n const parsed = parseCurrentResourceKeys(change);\n if (!parsed) {\n return { kind: \"drop\" };\n }\n // Normalize tenant / workspace / resource ids to lowercase before writing\n // so the Postgres composite PK is internally consistent regardless of the\n // casing the upstream pipeline supplies. `resource_type` is intentionally\n // NOT lowered — it carries the canonical FHIR PascalCase\n // (`Patient`, `ExplanationOfBenefit`) the upstream parser recovers from\n // the resource JSON. The search / soft-delete WHERE clauses keep LOWER()\n // on both sides as a defense against any legacy rows that escaped this\n // normalizer.\n const keys = {\n ...parsed,\n tenantId: parsed.tenantId.toLowerCase(),\n workspaceId: parsed.workspaceId.toLowerCase(),\n resourceId: parsed.resourceId.toLowerCase(),\n };\n\n const isRemove = change.eventName === \"REMOVE\";\n const image = isRemove\n ? change.dynamodb?.OldImage\n : change.dynamodb?.NewImage;\n if (!image) {\n return { kind: \"drop\" };\n }\n\n const plain = dynamodbImageToPlain(image);\n const approxEpochMs = change.dynamodb?.ApproximateCreationDateTime;\n\n if (isRemove) {\n return {\n kind: \"delete\",\n params: {\n tenantId: keys.tenantId,\n workspaceId: keys.workspaceId,\n resourceType: keys.resourceType,\n resourceId: keys.resourceId,\n version: keys.version,\n deletedAt: deriveLastUpdated(undefined, approxEpochMs),\n },\n };\n }\n\n if (typeof plain.resource !== \"string\") {\n return { kind: \"drop\" };\n }\n // The DynamoDB `resource` attribute carries a gzip envelope\n // (`{ v, algo, payload }`) when the write went through the data-operations\n // layer. Decompress before parsing so the JSONB column holds the raw FHIR\n // resource — otherwise JSONB path queries (`resource->>'resourceType'`,\n // GIN-anchored search params) see envelope keys (`v`, `algo`, `payload`)\n // instead of FHIR fields. `decompressResource` accepts uncompressed JSON\n // as a pass-through, so this is safe for legacy/raw inputs too.\n let resourceJson: string;\n let resourceObj: unknown;\n try {\n resourceJson = decompressResource(plain.resource);\n resourceObj = JSON.parse(resourceJson);\n } catch {\n return { kind: \"drop\" };\n }\n\n return {\n kind: \"upsert\",\n params: {\n tenantId: keys.tenantId,\n workspaceId: keys.workspaceId,\n resourceType: keys.resourceType,\n resourceId: keys.resourceId,\n version: keys.version,\n lastUpdated: deriveLastUpdated(resourceObj, approxEpochMs),\n resourceJson,\n },\n };\n}\n\ninterface PgQueryable {\n query: (\n text: string,\n values?: ReadonlyArray<unknown>,\n ) => Promise<unknown> | unknown;\n}\n\n/**\n * Execute a write intent against an open Postgres connection or pool. Pure\n * SQL dispatch — extracted from the handler so unit tests can drive it with a\n * mock client and integration tests can drive it with a real pool.\n */\nexport async function applyWriteIntent(\n client: PgQueryable,\n schemaName: string,\n intent: WriteIntent,\n): Promise<void> {\n if (intent.kind === \"drop\") {\n return;\n }\n if (intent.kind === \"upsert\") {\n const p = intent.params;\n await Promise.resolve(\n client.query(buildResourceUpsertSql(schemaName), [\n p.tenantId,\n p.workspaceId,\n p.resourceType,\n p.resourceId,\n p.version,\n p.lastUpdated,\n p.resourceJson,\n ]),\n );\n return;\n }\n const p = intent.params;\n await Promise.resolve(\n client.query(buildResourceSoftDeleteSql(schemaName), [\n p.tenantId,\n p.workspaceId,\n p.resourceType,\n p.resourceId,\n p.version,\n p.deletedAt,\n ]),\n );\n}\n\nexport async function handler(\n event: KinesisStreamEvent,\n): Promise<KinesisStreamBatchResponse> {\n const awsRegion = process.env.AWS_REGION ?? \"\";\n const batchItemFailures: Array<{ itemIdentifier: string }> = [];\n\n await ensureBootstrapped();\n const cfg = await loadConfig();\n const p = await getPool();\n\n for (const record of event.Records) {\n try {\n const change = decodeKinesisRecord(record);\n const intent = buildWriteIntent(change, awsRegion);\n await applyWriteIntent(p, cfg.schema, intent);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(\n \"postgres replication record failed\",\n record.kinesis.sequenceNumber,\n err,\n );\n batchItemFailures.push({\n itemIdentifier: record.kinesis.sequenceNumber,\n });\n }\n }\n\n return { batchItemFailures };\n}\n"],"mappings":"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","names":["p"]}
|
|
@@ -10964,6 +10964,16 @@ function jsonbPathToStringShape(jsonbPath) {
|
|
|
10964
10964
|
if (arrayOfScalars) {
|
|
10965
10965
|
return { kind: "array-of-scalars", field: arrayOfScalars[1] };
|
|
10966
10966
|
}
|
|
10967
|
+
const arrayOfArrays = /^\$\.([A-Za-z_][A-Za-z0-9_]*)\[\*\]\.([A-Za-z_][A-Za-z0-9_]*)\[\*\]$/.exec(
|
|
10968
|
+
jsonbPath
|
|
10969
|
+
);
|
|
10970
|
+
if (arrayOfArrays) {
|
|
10971
|
+
return {
|
|
10972
|
+
kind: "array-of-arrays",
|
|
10973
|
+
field: arrayOfArrays[1],
|
|
10974
|
+
subfield: arrayOfArrays[2]
|
|
10975
|
+
};
|
|
10976
|
+
}
|
|
10967
10977
|
const arrayOfObjs = /^\$\.([A-Za-z_][A-Za-z0-9_]*)\[\*\]\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(
|
|
10968
10978
|
jsonbPath
|
|
10969
10979
|
);
|
|
@@ -10975,7 +10985,7 @@ function jsonbPathToStringShape(jsonbPath) {
|
|
|
10975
10985
|
};
|
|
10976
10986
|
}
|
|
10977
10987
|
throw new Error(
|
|
10978
|
-
`String predicate cannot translate JSONPath "${jsonbPath}". Supported shapes: "$.field", "$.field[*]", "$.field[*].subfield".`
|
|
10988
|
+
`String predicate cannot translate JSONPath "${jsonbPath}". Supported shapes: "$.field", "$.field[*]", "$.field[*].subfield", "$.field[*].subfield[*]".`
|
|
10979
10989
|
);
|
|
10980
10990
|
}
|
|
10981
10991
|
function escapeLikePattern(value) {
|
|
@@ -11008,6 +11018,13 @@ function buildIlikeExtractSql(shape, paramName) {
|
|
|
11008
11018
|
`jsonb_array_elements(resource->'${shape.field}') AS s_obj(obj)`,
|
|
11009
11019
|
`WHERE s_obj.obj->>'${shape.subfield}' ILIKE :${paramName})`
|
|
11010
11020
|
].join(" ");
|
|
11021
|
+
case "array-of-arrays":
|
|
11022
|
+
return [
|
|
11023
|
+
"EXISTS (SELECT 1 FROM",
|
|
11024
|
+
`jsonb_array_elements(resource->'${shape.field}') AS s_obj(obj),`,
|
|
11025
|
+
`jsonb_array_elements_text(s_obj.obj->'${shape.subfield}') AS s_elem(text_val)`,
|
|
11026
|
+
`WHERE s_elem.text_val ILIKE :${paramName})`
|
|
11027
|
+
].join(" ");
|
|
11011
11028
|
}
|
|
11012
11029
|
}
|
|
11013
11030
|
function emitStringPredicate(opts) {
|
|
@@ -11248,8 +11265,8 @@ function buildGenericSearchSql(opts) {
|
|
|
11248
11265
|
const lines = [
|
|
11249
11266
|
"SELECT resource_id AS id, resource",
|
|
11250
11267
|
"FROM resources",
|
|
11251
|
-
"WHERE tenant_id = :tenantId",
|
|
11252
|
-
" AND workspace_id = :workspaceId",
|
|
11268
|
+
"WHERE LOWER(tenant_id) = LOWER(:tenantId)",
|
|
11269
|
+
" AND LOWER(workspace_id) = LOWER(:workspaceId)",
|
|
11253
11270
|
" AND LOWER(resource_type) = LOWER(:resourceType)",
|
|
11254
11271
|
" AND deleted_at IS NULL"
|
|
11255
11272
|
];
|
|
@@ -11280,12 +11297,23 @@ async function genericSearchOperation(params) {
|
|
|
11280
11297
|
...combined.params
|
|
11281
11298
|
];
|
|
11282
11299
|
const rows = await runner.query(sql, queryParams);
|
|
11283
|
-
const entries = rows.map((row) =>
|
|
11284
|
-
|
|
11285
|
-
|
|
11286
|
-
|
|
11300
|
+
const entries = rows.map((row) => {
|
|
11301
|
+
const parsed = parseResourceColumn(row.resource);
|
|
11302
|
+
const bodyId = typeof parsed.id === "string" ? parsed.id : void 0;
|
|
11303
|
+
const id = bodyId ?? row.id;
|
|
11304
|
+
return {
|
|
11305
|
+
id,
|
|
11306
|
+
resource: { ...parsed, id }
|
|
11307
|
+
};
|
|
11308
|
+
});
|
|
11287
11309
|
return { entries, total: entries.length };
|
|
11288
11310
|
}
|
|
11311
|
+
function parseResourceColumn(raw) {
|
|
11312
|
+
if (typeof raw === "string") {
|
|
11313
|
+
return JSON.parse(raw);
|
|
11314
|
+
}
|
|
11315
|
+
return raw;
|
|
11316
|
+
}
|
|
11289
11317
|
|
|
11290
11318
|
// src/data/search/registry/allergyintolerance-search-parameters.ts
|
|
11291
11319
|
var ALLERGYINTOLERANCE_SEARCH_PARAMETERS = [
|
|
@@ -11886,7 +11914,7 @@ var PATIENT_SEARCH_PARAMETERS = [
|
|
|
11886
11914
|
{
|
|
11887
11915
|
code: "given",
|
|
11888
11916
|
type: "string",
|
|
11889
|
-
jsonbPath: "$.name[*].given",
|
|
11917
|
+
jsonbPath: "$.name[*].given[*]",
|
|
11890
11918
|
modifiers: ["exact", "contains", "missing", "not"]
|
|
11891
11919
|
},
|
|
11892
11920
|
{ code: "active", type: "token", jsonbPath: "$.active" },
|
|
@@ -11936,7 +11964,7 @@ var PRACTITIONER_SEARCH_PARAMETERS = [
|
|
|
11936
11964
|
{
|
|
11937
11965
|
code: "given",
|
|
11938
11966
|
type: "string",
|
|
11939
|
-
jsonbPath: "$.name[*].given",
|
|
11967
|
+
jsonbPath: "$.name[*].given[*]",
|
|
11940
11968
|
modifiers: ["exact", "contains", "missing", "not"]
|
|
11941
11969
|
},
|
|
11942
11970
|
{ code: "active", type: "token", jsonbPath: "$.active" },
|