@openhi/constructs 0.0.176 → 0.0.177

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.
@@ -9,7 +9,7 @@ import {
9
9
  buildResourceSoftDeleteSql,
10
10
  buildResourceUpsertSql,
11
11
  ensureSchemaBootstrap
12
- } from "./chunk-XJ5SRUGN.mjs";
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 keys = parseCurrentResourceKeys(change);
151
- if (!keys) {
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"]}
@@ -11248,8 +11248,8 @@ function buildGenericSearchSql(opts) {
11248
11248
  const lines = [
11249
11249
  "SELECT resource_id AS id, resource",
11250
11250
  "FROM resources",
11251
- "WHERE tenant_id = :tenantId",
11252
- " AND workspace_id = :workspaceId",
11251
+ "WHERE LOWER(tenant_id) = LOWER(:tenantId)",
11252
+ " AND LOWER(workspace_id) = LOWER(:workspaceId)",
11253
11253
  " AND LOWER(resource_type) = LOWER(:resourceType)",
11254
11254
  " AND deleted_at IS NULL"
11255
11255
  ];