diff --git a/README.md b/README.md index 7ea1ef4..3ea3a85 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ const client = new AlternatorDynamoDBClient({ seeds: ["scylla-0.internal", "scylla-1.internal"], scheme: "http", port: 8080, - routing: routing.datacenter("dc1", { + routing: routing.datacenter({ + datacenter: "dc1", fallback: routing.cluster(), }), @@ -51,7 +52,7 @@ await client.send( client.destroy(); ``` -`endpoint` is rejected. Use `seeds`, optional `scheme`, and a shared `port`. +The public config does not include `endpoint`. Use `seeds`, optional `scheme`, and a shared `port`. Defaults are `scheme: "http"` and `port: 8080`. HTTPS users usually set `scheme: "https"` and `port: 8043`. @@ -65,7 +66,7 @@ signing is preserved. `region` defaults to `us-east-1` for signing. import { PutCommand } from "@aws-sdk/lib-dynamodb"; import { AlternatorDynamoDBDocumentClient } from "@scylladb/alternator-client/document"; -const docClient = new AlternatorDynamoDBDocumentClient( +const docClient = AlternatorDynamoDBDocumentClient.fromConfig( { seeds: ["localhost"] }, { marshallOptions: { removeUndefinedValues: true } }, ); @@ -78,7 +79,7 @@ await docClient.send( ); ``` -AWS-style wrapping is also supported: +AWS-style wrapping is the primary API when you already have a low-level client: ```ts const base = new AlternatorDynamoDBClient({ seeds: ["localhost"] }); @@ -91,21 +92,25 @@ balancing unless the passed client is already an `AlternatorDynamoDBClient`. ## Alternator APIs ```ts -client.getLiveNodes(); -await client.refreshLiveNodes(); -await client.checkRackDatacenterSupport(); -await client.checkIfRackAndDatacenterSetCorrectly(); -await client.validateRackDatacenterConfig(); -client.getPartitionKeyName("users"); +client.alternator.nodes(); +await client.alternator.refreshNodes(); +await client.alternator.supportsScopedDiscovery(); +await client.alternator.validateRouting(); +client.alternator.partitionKey("users"); ``` Routing helpers: ```ts routing.cluster(); -routing.datacenter("dc1", { fallback: routing.cluster() }); -routing.rack("dc1", "rack1", { - fallback: routing.datacenter("dc1", { fallback: routing.cluster() }), +routing.datacenter({ datacenter: "dc1", fallback: routing.cluster() }); +routing.rack({ + datacenter: "dc1", + rack: "rack1", + fallback: routing.datacenter({ + datacenter: "dc1", + fallback: routing.cluster(), + }), }); ``` @@ -152,25 +157,22 @@ new AlternatorDynamoDBClient({ compression: { request: { - enabled: true, thresholdBytes: 1_024, gzipLevel: -1, }, response: { - enabled: true, - algorithms: [ResponseCompressionGzip], + algorithms: ["gzip"], }, }, headerOptimization: { - enabled: true, allowedHeaders: ["Host", "X-Amz-Target", "Content-Length", "Accept-Encoding", "Content-Encoding"], }, - userAgent: (userAgent) => `${userAgent} my-app/1.2.3`, + userAgent: { append: "my-app/1.2.3" }, keyRouteAffinity: { - type: "any-write", + mode: "any-write", partitionKeys: { users: "id", }, @@ -178,7 +180,7 @@ new AlternatorDynamoDBClient({ }, tls: { - caFile: "/etc/ssl/scylla-ca.pem", + ca: { file: "/etc/ssl/scylla-ca.pem" }, rejectUnauthorized: true, sessionCache: true, }, @@ -186,9 +188,11 @@ new AlternatorDynamoDBClient({ connection: { keepAlive: true, maxSockets: 50, - connectionTimeoutMs: 1_000, - requestTimeoutMs: 0, - socketTimeoutMs: 0, + timeouts: { + connectMs: 1_000, + requestMs: 0, + socketMs: 0, + }, }, }); ``` @@ -217,17 +221,16 @@ You can replace it completely: ```ts new AlternatorDynamoDBClient({ seeds: ["scylla-0.internal"], - userAgent: "my-client/1.2.3", + userAgent: { value: "my-client/1.2.3" }, }); ``` -You can transform the generated value. The function receives the default -ScyllaDB Alternator user-agent: +You can append to the generated value: ```ts new AlternatorDynamoDBClient({ seeds: ["scylla-0.internal"], - userAgent: (userAgent) => `${userAgent} my-app/4.5.6`, + userAgent: { append: "my-app/4.5.6" }, }); ``` @@ -239,37 +242,30 @@ separately: ```ts compression: { request: { - enabled: true, thresholdBytes: 1_024, gzipLevel: -1, }, response: { - enabled: true, - algorithms: [ResponseCompressionGzip], + algorithms: ["gzip"], }, } ``` -`compression.request: true` compresses every measurable request body with gzip. +`compression.request: {}` compresses every measurable request body with gzip. Use `thresholdBytes` to skip smaller request bodies, `gzipLevel` to select the -zlib level, or `compressor` for a custom request compressor. +zlib level, or `compressor` for a custom request compressor. Use +`compression.request: false` to disable request compression explicitly. -`compression.response: true` enables the default response algorithm, `gzip`. +`compression.response: {}` enables the default response algorithm, `gzip`. Pass an options object with an explicit algorithm list to control the `Accept-Encoding` value: ```ts -import { - ResponseCompressionDeflate, - ResponseCompressionGzip, -} from "@scylladb/alternator-client"; - new AlternatorDynamoDBClient({ seeds: ["scylla-0.internal"], compression: { response: { - enabled: true, - algorithms: [ResponseCompressionGzip, ResponseCompressionDeflate], + algorithms: ["gzip", "deflate"], }, }, }); @@ -284,7 +280,7 @@ Key-route affinity supports these modes: ```ts keyRouteAffinity: { - type: "read-before-write", // or "any-write" + mode: "read-before-write", // or "any-write" partitionKeys: { users: "id" }, } ``` diff --git a/src/affinity.ts b/src/affinity.ts index 1ecc9d6..de00b5b 100644 --- a/src/affinity.ts +++ b/src/affinity.ts @@ -1,7 +1,7 @@ import { AlternatorQueryPlan, firstNodeWithSeed } from "./query-plan.js"; import { murmur3H1 } from "./murmur.js"; import type { - AlternatorKeyRouteAffinityType, + AlternatorKeyRouteAffinityMode, AlternatorLogger, AlternatorNode, NormalizedKeyRouteAffinityOptions, @@ -48,11 +48,11 @@ export class KeyRouteAffinityPlanner { return undefined; } - if (isBatchWriteCommand(commandName) && isBatchWriteInput(input) && this.config.type === "any-write") { + if (isBatchWriteCommand(commandName) && isBatchWriteInput(input) && this.config.mode === "any-write") { return this.batchWriteQueryPlan(input.RequestItems, nodes); } - const hash = this.partitionKeyHashForInput(input, this.config.type, commandName); + const hash = this.partitionKeyHashForInput(input, this.config.mode, commandName); if (hash === undefined) { return undefined; } @@ -61,10 +61,10 @@ export class KeyRouteAffinityPlanner { private partitionKeyHashForInput( input: DynamoDBInput, - affinityType: AlternatorKeyRouteAffinityType, + affinityMode: AlternatorKeyRouteAffinityMode, commandName?: string, ): bigint | undefined { - const request = writeRequestForInput(input, affinityType, commandName); + const request = writeRequestForInput(input, affinityMode, commandName); if (!request) { return undefined; } @@ -171,7 +171,7 @@ function hashWithPrefix(prefix: number, bytes: Uint8Array): bigint { function writeRequestForInput( input: DynamoDBInput, - affinityType: AlternatorKeyRouteAffinityType, + affinityMode: AlternatorKeyRouteAffinityMode, commandName?: string, ): { tableName: string; values: AttributeValueRecord } | undefined { const tableName = typeof input.TableName === "string" ? input.TableName : undefined; @@ -179,40 +179,34 @@ function writeRequestForInput( return undefined; } - if (isPutCommand(commandName) && isRecord(input.Item) && shouldRoutePut(input, affinityType)) { + if (isPutCommand(commandName) && isRecord(input.Item) && shouldRoutePut(input, affinityMode)) { return { tableName, values: input.Item }; } - if (isUpdateCommand(commandName) && isRecord(input.Key) && shouldRouteUpdate(input, affinityType)) { + if (isUpdateCommand(commandName) && isRecord(input.Key) && shouldRouteUpdate(input, affinityMode)) { return { tableName, values: input.Key }; } - if (isDeleteCommand(commandName) && isRecord(input.Key) && shouldRouteDelete(input, affinityType)) { + if (isDeleteCommand(commandName) && isRecord(input.Key) && shouldRouteDelete(input, affinityMode)) { return { tableName, values: input.Key }; } return undefined; } -function shouldRoutePut(input: DynamoDBInput, affinityType: AlternatorKeyRouteAffinityType): boolean { +function shouldRoutePut(input: DynamoDBInput, affinityMode: AlternatorKeyRouteAffinityMode): boolean { if (!("Item" in input)) { return false; } - if (affinityType === "none") { - return false; - } - if (affinityType === "any-write") { + if (affinityMode === "any-write") { return true; } return hasExpected(input) || nonEmptyString(input.ConditionExpression) || input.ReturnValues === "ALL_OLD"; } -function shouldRouteUpdate(input: DynamoDBInput, affinityType: AlternatorKeyRouteAffinityType): boolean { +function shouldRouteUpdate(input: DynamoDBInput, affinityMode: AlternatorKeyRouteAffinityMode): boolean { if (!("Key" in input) || !("UpdateExpression" in input || "AttributeUpdates" in input)) { return false; } - if (affinityType === "none") { - return false; - } - if (affinityType === "any-write") { + if (affinityMode === "any-write") { return true; } @@ -241,14 +235,11 @@ function shouldRouteUpdate(input: DynamoDBInput, affinityType: AlternatorKeyRout return false; } -function shouldRouteDelete(input: DynamoDBInput, affinityType: AlternatorKeyRouteAffinityType): boolean { +function shouldRouteDelete(input: DynamoDBInput, affinityMode: AlternatorKeyRouteAffinityMode): boolean { if (!("Key" in input) || "UpdateExpression" in input || "AttributeUpdates" in input) { return false; } - if (affinityType === "none") { - return false; - } - if (affinityType === "any-write") { + if (affinityMode === "any-write") { return true; } return hasExpected(input) || nonEmptyString(input.ConditionExpression) || input.ReturnValues === "ALL_OLD"; diff --git a/src/client.ts b/src/client.ts index add485f..3e3405c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -14,8 +14,17 @@ import { createAlternatorPostSigningMiddleware, createAlternatorRequestMiddlewar import { assertRuntimeSupport, createRequestHandler } from "./runtime.js"; import type { AlternatorDynamoDBClientConfig, AlternatorNode, NormalizedAlternatorConfig } from "./types.js"; +export interface AlternatorDynamoDBClientApi { + nodes(): AlternatorNode[]; + refreshNodes(): Promise; + supportsScopedDiscovery(): Promise; + validateRouting(): Promise; + partitionKey(tableName: string): string | undefined; +} + export class AlternatorDynamoDBClient extends DynamoDBClient { - readonly alternatorConfig: NormalizedAlternatorConfig; + readonly alternator: AlternatorDynamoDBClientApi; + private readonly alternatorConfig: NormalizedAlternatorConfig; private readonly discovery: AlternatorDiscovery; private readonly keyAffinity: KeyRouteAffinityPlanner; @@ -38,6 +47,13 @@ export class AlternatorDynamoDBClient extends DynamoDBClient { (tableName) => this.discoverPartitionKey(tableName), alternatorConfig.logger, ); + this.alternator = { + nodes: () => this.discovery.getLiveNodes(), + refreshNodes: () => this.discovery.refreshLiveNodes(), + supportsScopedDiscovery: () => this.discovery.checkRackDatacenterSupport(), + validateRouting: () => this.discovery.checkIfRackAndDatacenterSetCorrectly(), + partitionKey: (tableName) => this.keyAffinity.getPartitionKeyName(tableName), + }; this.middlewareStack.addRelativeTo( createAlternatorRequestMiddleware({ @@ -65,30 +81,6 @@ export class AlternatorDynamoDBClient extends DynamoDBClient { } - getLiveNodes(): AlternatorNode[] { - return this.discovery.getLiveNodes(); - } - - refreshLiveNodes(): Promise { - return this.discovery.refreshLiveNodes(); - } - - checkRackDatacenterSupport(): Promise { - return this.discovery.checkRackDatacenterSupport(); - } - - checkIfRackAndDatacenterSetCorrectly(): Promise { - return this.discovery.checkIfRackAndDatacenterSetCorrectly(); - } - - validateRackDatacenterConfig(): Promise { - return this.checkIfRackAndDatacenterSetCorrectly(); - } - - getPartitionKeyName(tableName: string): string | undefined { - return this.keyAffinity.getPartitionKeyName(tableName); - } - override destroy(): void { this.discovery.destroy(); super.destroy(); @@ -125,11 +117,12 @@ function buildDynamoConfig( tls: _tls, discovery: _discovery, connection: _connection, - endpoint: _endpoint, + logger: _logger, credentials, region, - ...awsConfig - } = input; + ...awsConfigWithEndpoint + } = input as AlternatorDynamoDBClientConfig & { endpoint?: unknown }; + const { endpoint: _endpoint, ...awsConfig } = awsConfigWithEndpoint; const dynamoConfig: DynamoDBClientConfig & { applyChecksum?: boolean } = { ...awsConfig, diff --git a/src/compression.ts b/src/compression.ts index 3611b41..fc3b83a 100644 --- a/src/compression.ts +++ b/src/compression.ts @@ -1,6 +1,5 @@ import { HttpResponse } from "@smithy/protocol-http"; import { bodyToBytes } from "./body.js"; -import { ResponseCompressionDeflate, ResponseCompressionGzip } from "./types.js"; import type { AlternatorResponseCompressionAlgorithm, AlternatorRuntime, @@ -125,14 +124,14 @@ async function decompressNodeResponseBody( const zlib = await import("node:zlib"); if (isNodePipeableBody(body)) { - const decoder = encoding === ResponseCompressionGzip + const decoder = encoding === "gzip" ? zlib.createGunzip() : zlib.createInflate(); return body.pipe(decoder); } const bytes = await bodyToAsyncBytes(body); - return encoding === ResponseCompressionGzip + return encoding === "gzip" ? zlib.gunzipSync(bytes) : zlib.inflateSync(bytes); } @@ -188,10 +187,10 @@ async function bodyToAsyncBytes(body: unknown): Promise { function responseContentEncoding(value: string | undefined): AlternatorResponseCompressionAlgorithm | undefined { switch (value?.trim().toLowerCase()) { - case ResponseCompressionGzip: - return ResponseCompressionGzip; - case ResponseCompressionDeflate: - return ResponseCompressionDeflate; + case "gzip": + return "gzip"; + case "deflate": + return "deflate"; default: return undefined; } diff --git a/src/config.ts b/src/config.ts index 52952eb..6d5e3e8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,13 +1,12 @@ import { routing } from "./routing.js"; import { normalizeLogger } from "./logger.js"; import { normalizeUserAgent } from "./user-agent.js"; -import type { RoutingRule } from "./routing.js"; -import { ResponseCompressionDeflate, ResponseCompressionGzip } from "./types.js"; +import type { AlternatorRoutingScope } from "./routing.js"; import type { AlternatorCompressionOptions, AlternatorConnectionOptions, AlternatorDynamoDBClientConfig, - AlternatorKeyRouteAffinityType, + AlternatorKeyRouteAffinityMode, AlternatorRequestCompressionConfig, AlternatorResponseCompressionAlgorithm, AlternatorResponseCompressionConfig, @@ -26,7 +25,7 @@ const DEFAULT_ALLOWED_HEADERS = [ "Content-Encoding", ] as const; const DEFAULT_RESPONSE_COMPRESSION_ALGORITHMS = [ - ResponseCompressionGzip, + "gzip", ] as const; export const DEFAULT_REGION = "us-east-1"; @@ -103,8 +102,9 @@ export function hostForUrl(host: string): string { } function assertNoEndpoint(input: AlternatorDynamoDBClientConfig): void { - if ("endpoint" in input && input.endpoint !== undefined) { - throw new TypeError("AlternatorDynamoDBClient requires seeds; do not pass endpoint"); + const maybeEndpoint = input as AlternatorDynamoDBClientConfig & { endpoint?: unknown }; + if ("endpoint" in maybeEndpoint && maybeEndpoint.endpoint !== undefined) { + throw new TypeError("AlternatorDynamoDBClient uses seeds, scheme, and port instead of endpoint"); } } @@ -155,16 +155,16 @@ function normalizeSeed(seed: string): string { return trimmed; } -function normalizeRouting(input: AlternatorDynamoDBClientConfig["routing"]): RoutingRule { +function normalizeRouting(input: AlternatorDynamoDBClientConfig["routing"]): AlternatorRoutingScope { if (input === undefined) { return routing.cluster(); } - return normalizeRoutingRule(input, "routing"); + return normalizeRoutingScope(input, "routing"); } -function normalizeRoutingRule(input: unknown, label: string): RoutingRule { +function normalizeRoutingScope(input: unknown, label: string): AlternatorRoutingScope { if (!isRecord(input)) { - throw new TypeError(`${label} must be a routing rule object`); + throw new TypeError(`${label} must be a routing scope object`); } switch (input.kind) { @@ -172,20 +172,18 @@ function normalizeRoutingRule(input: unknown, label: string): RoutingRule { return routing.cluster(); case "datacenter": assertNonEmptyString(input.datacenter, `${label}.datacenter`); - return { - kind: "datacenter", + return routing.datacenter({ datacenter: input.datacenter, ...normalizeRoutingFallback(input.fallback, label), - }; + }); case "rack": assertNonEmptyString(input.datacenter, `${label}.datacenter`); assertNonEmptyString(input.rack, `${label}.rack`); - return { - kind: "rack", + return routing.rack({ datacenter: input.datacenter, rack: input.rack, ...normalizeRoutingFallback(input.fallback, label), - }; + }); default: throw new TypeError(`${label}.kind must be "cluster", "datacenter", or "rack"`); } @@ -194,11 +192,11 @@ function normalizeRoutingRule(input: unknown, label: string): RoutingRule { function normalizeRoutingFallback( fallback: unknown, label: string, -): { fallback?: RoutingRule } { +): { fallback?: AlternatorRoutingScope } { if (fallback === undefined) { return {}; } - return { fallback: normalizeRoutingRule(fallback, `${label}.fallback`) }; + return { fallback: normalizeRoutingScope(fallback, `${label}.fallback`) }; } function normalizeRuntime(runtime: AlternatorDynamoDBClientConfig["runtime"]): AlternatorRuntime { @@ -230,22 +228,20 @@ function normalizeCompression(input: AlternatorDynamoDBClientConfig["compression function normalizeRequestCompression( input: AlternatorRequestCompressionConfig | undefined, ): NormalizedRequestCompressionOptions { - if (typeof input === "boolean") { - return { enabled: input, thresholdBytes: 0 }; - } - if (input === undefined) { + if (input === undefined || input === false) { return { enabled: false, thresholdBytes: 0 }; } if (!isRecord(input)) { - throw new TypeError("compression.request must be a boolean or request compression object"); + throw new TypeError("compression.request must be false or a request compression object"); } - const options = input as Exclude; + assertAllowedKeys(input, ["thresholdBytes", "gzipLevel", "compressor"], "compression.request"); + const options = input as Exclude; const gzipLevel = options.gzipLevel; if (gzipLevel !== undefined && (gzipLevel < -1 || gzipLevel > 9)) { throw new TypeError("compression.request.gzipLevel must be between -1 and 9"); } return { - enabled: options.enabled ?? false, + enabled: true, thresholdBytes: options.thresholdBytes ?? 0, ...(gzipLevel !== undefined ? { gzipLevel } : {}), ...(options.compressor ? { compressor: options.compressor } : {}), @@ -272,31 +268,22 @@ function normalizeResponseCompression( function configuredResponseAlgorithms( input: NonNullable, ): readonly unknown[] { - if (input === true) { - return DEFAULT_RESPONSE_COMPRESSION_ALGORITHMS; - } if (!isRecord(input)) { - throw new TypeError("compression.response must be a boolean or options object"); - } - assertAllowedKeys(input, ["enabled", "algorithms"], "compression.response"); - if (input.enabled === false) { - return []; + throw new TypeError("compression.response must be false or an options object"); } + assertAllowedKeys(input, ["algorithms"], "compression.response"); if (input.algorithms !== undefined) { if (!Array.isArray(input.algorithms)) { throw new TypeError("compression.response.algorithms must be an array"); } } - if (input.enabled !== true) { - return []; - } return input.algorithms ?? DEFAULT_RESPONSE_COMPRESSION_ALGORITHMS; } function normalizeResponseCompressionAlgorithm(algorithm: unknown): AlternatorResponseCompressionAlgorithm { switch (algorithm) { - case ResponseCompressionGzip: - case ResponseCompressionDeflate: + case "gzip": + case "deflate": return algorithm; default: throw new TypeError('compression.response algorithms must be "gzip" or "deflate"'); @@ -310,27 +297,53 @@ function normalizeHeaderOptimization(input: AlternatorDynamoDBClientConfig["head allowedHeaders: defaultAllowedHeaders(noAuth), }; } + if (input === undefined) { + return { + enabled: false, + allowedHeaders: defaultAllowedHeaders(noAuth), + }; + } + assertAllowedKeys(input as Record, ["allowedHeaders", "additionalAllowedHeaders"], "headerOptimization"); + const baseHeaders = input.allowedHeaders ?? defaultAllowedHeaders(noAuth); return { - enabled: input?.enabled ?? false, - allowedHeaders: input?.allowedHeaders ?? input?.stripHeaders ?? defaultAllowedHeaders(noAuth), + enabled: true, + allowedHeaders: [ + ...baseHeaders, + ...(input.additionalAllowedHeaders ?? []), + ], }; } function normalizeKeyRouteAffinity(input: AlternatorDynamoDBClientConfig["keyRouteAffinity"]) { - if (typeof input === "boolean") { + if (input === undefined || input === false) { return { - enabled: input, - type: input ? "any-write" : "none", + enabled: false, + mode: "any-write", partitionKeys: new Map(), - autoDiscoverPartitionKeys: input, + autoDiscoverPartitionKeys: false, } as const; } - const type = normalizeKeyRouteAffinityType(input?.type ?? (input?.enabled ? "any-write" : "none")); + if (!isRecord(input)) { + throw new TypeError("keyRouteAffinity must be false or an options object"); + } + assertAllowedKeys(input, ["mode", "partitionKeys", "autoDiscoverPartitionKeys"], "keyRouteAffinity"); + const options = input as { + mode?: unknown; + partitionKeys?: unknown; + autoDiscoverPartitionKeys?: unknown; + }; + if ( + options.autoDiscoverPartitionKeys !== undefined && + typeof options.autoDiscoverPartitionKeys !== "boolean" + ) { + throw new TypeError("keyRouteAffinity.autoDiscoverPartitionKeys must be a boolean"); + } + const mode = normalizeKeyRouteAffinityMode(options.mode ?? "any-write"); return { - enabled: type !== "none" && input?.enabled !== false, - type, - partitionKeys: normalizePartitionKeys(input?.partitionKeys), - autoDiscoverPartitionKeys: input?.autoDiscoverPartitionKeys ?? type !== "none", + enabled: true, + mode, + partitionKeys: normalizePartitionKeys(options.partitionKeys), + autoDiscoverPartitionKeys: options.autoDiscoverPartitionKeys ?? true, } as const; } @@ -338,13 +351,20 @@ function normalizePartitionKeys(partitionKeys: unknown): Map { if (partitionKeys === undefined) { return new Map(); } + if (partitionKeys instanceof Map) { + return normalizePartitionKeyEntries(partitionKeys.entries()); + } if (typeof partitionKeys !== "object" || partitionKeys === null || Array.isArray(partitionKeys)) { - throw new TypeError("keyRouteAffinity.partitionKeys must be an object mapping table names to partition keys"); + throw new TypeError("keyRouteAffinity.partitionKeys must be an object or map from table names to partition keys"); } + return normalizePartitionKeyEntries(Object.entries(partitionKeys)); +} + +function normalizePartitionKeyEntries(entries: Iterable): Map { const normalized = new Map(); - for (const [tableName, keyName] of Object.entries(partitionKeys)) { - if (tableName.trim() === "") { + for (const [tableName, keyName] of entries) { + if (typeof tableName !== "string" || tableName.trim() === "") { throw new TypeError("keyRouteAffinity.partitionKeys table names must be non-empty strings"); } if (typeof keyName !== "string" || keyName.trim() === "") { @@ -355,11 +375,11 @@ function normalizePartitionKeys(partitionKeys: unknown): Map { return normalized; } -function normalizeKeyRouteAffinityType(type: unknown): AlternatorKeyRouteAffinityType { - if (type === "none" || type === "read-before-write" || type === "any-write") { - return type; +function normalizeKeyRouteAffinityMode(mode: unknown): AlternatorKeyRouteAffinityMode { + if (mode === "read-before-write" || mode === "any-write") { + return mode; } - throw new TypeError('keyRouteAffinity.type must be "none", "read-before-write", or "any-write"'); + throw new TypeError('keyRouteAffinity.mode must be "read-before-write" or "any-write"'); } function normalizeDiscovery( @@ -383,20 +403,20 @@ function normalizeDiscovery( } function normalizeConnection(input: AlternatorConnectionOptions): AlternatorConnectionOptions { - if (input.node !== undefined && ("httpAgent" in input.node || "httpsAgent" in input.node)) { + if ("node" in input && input.node !== undefined && ("httpAgent" in input.node || "httpsAgent" in input.node)) { throw new TypeError("connection.node cannot include httpAgent or httpsAgent; use Alternator connection and tls options"); } - if (input.maxSockets !== undefined) { + if ("maxSockets" in input && input.maxSockets !== undefined) { assertPositive(input.maxSockets, "connection.maxSockets"); } - if (input.connectionTimeoutMs !== undefined) { - assertNonNegative(input.connectionTimeoutMs, "connection.connectionTimeoutMs"); + if (input.timeouts && "connectMs" in input.timeouts && input.timeouts.connectMs !== undefined) { + assertNonNegative(input.timeouts.connectMs, "connection.timeouts.connectMs"); } - if (input.requestTimeoutMs !== undefined) { - assertNonNegative(input.requestTimeoutMs, "connection.requestTimeoutMs"); + if (input.timeouts?.requestMs !== undefined) { + assertNonNegative(input.timeouts.requestMs, "connection.timeouts.requestMs"); } - if (input.socketTimeoutMs !== undefined) { - assertNonNegative(input.socketTimeoutMs, "connection.socketTimeoutMs"); + if (input.timeouts && "socketMs" in input.timeouts && input.timeouts.socketMs !== undefined) { + assertNonNegative(input.timeouts.socketMs, "connection.timeouts.socketMs"); } return input; } diff --git a/src/discovery.ts b/src/discovery.ts index 25bc9de..75a6ae9 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -2,7 +2,7 @@ import { HttpRequest } from "@smithy/protocol-http"; import type { HttpHandlerOptions } from "@smithy/types"; import { bodyToString } from "./body.js"; import { hostForUrl, nodeUrl } from "./config.js"; -import { routingChain, type LocalNodesQuery, type RoutingRule } from "./routing.js"; +import { routingChain, type AlternatorRoutingScope, type LocalNodesQuery } from "./routing.js"; import { AlternatorQueryPlan } from "./query-plan.js"; import type { AlternatorNode, NormalizedAlternatorConfig } from "./types.js"; @@ -88,8 +88,8 @@ export class AlternatorDiscovery { let datacenterSupport: RackDatacenterSupport | undefined; let rackSupport: RackDatacenterSupport | undefined; - for (const rule of routingChain(this.config.routing)) { - if (rule.kind === "cluster") { + for (const scope of routingChain(this.config.routing)) { + if (scope.kind === "cluster") { return; } @@ -97,20 +97,20 @@ export class AlternatorDiscovery { if (datacenterSupport === "unsupported") { throw new Error("Alternator /localnodes does not support datacenter query parameters"); } - if (rule.kind === "rack") { + if (scope.kind === "rack") { rackSupport ??= await this.probeRackDatacenterSupport("rack"); if (rackSupport === "unsupported") { throw new Error("Alternator /localnodes does not support rack query parameters"); } } - const query = queryForRoutingRule(rule); + const query = queryForRoutingScope(scope); try { const nodes = await this.fetchFirstAvailableLocalNodes(query); if (nodes.length > 0) { return; } - errors.push(`scope ${routingRuleLabel(rule)} has no nodes`); + errors.push(`scope ${routingScopeLabel(scope)} has no nodes`); } catch (error) { throw new Error(`failed to read list of nodes: ${errorMessage(error)}`); } @@ -134,29 +134,29 @@ export class AlternatorDiscovery { let datacenterSupport: RackDatacenterSupport | undefined; let rackSupport: RackDatacenterSupport | undefined; - for (const rule of routingChain(this.config.routing)) { + for (const scope of routingChain(this.config.routing)) { try { let nodes: string[]; - if (rule.kind === "cluster") { + if (scope.kind === "cluster") { nodes = await this.fetchClusterLocalNodes(); } else { datacenterSupport ??= await this.probeRackDatacenterSupport("datacenter"); if (datacenterSupport === "unsupported") { this.config.logger.debug?.("alternator discovery: datacenter query parameters are unsupported", { - rule: routingRuleLabel(rule), + scope: routingScopeLabel(scope), }); continue; } - if (rule.kind === "rack") { + if (scope.kind === "rack") { rackSupport ??= await this.probeRackDatacenterSupport("rack"); if (rackSupport === "unsupported") { this.config.logger.debug?.("alternator discovery: rack query parameters are unsupported", { - rule: routingRuleLabel(rule), + scope: routingScopeLabel(scope), }); continue; } } - nodes = await this.fetchFirstAvailableLocalNodes(queryForRoutingRule(rule), candidates); + nodes = await this.fetchFirstAvailableLocalNodes(queryForRoutingScope(scope), candidates); } if (nodes.length > 0) { this.liveHosts = normalizeDiscoveredHosts(nodes); @@ -164,7 +164,7 @@ export class AlternatorDiscovery { } } catch (error) { this.config.logger.debug?.("alternator discovery: localnodes request failed", { - rule: routingRuleLabel(rule), + scope: routingScopeLabel(scope), error, }); } @@ -299,25 +299,25 @@ function hostHeader(host: string, port: number): string { return `${hostForUrl(host)}:${port}`; } -function queryForRoutingRule(rule: RoutingRule): LocalNodesQuery { - switch (rule.kind) { +function queryForRoutingScope(scope: AlternatorRoutingScope): LocalNodesQuery { + switch (scope.kind) { case "cluster": return {}; case "datacenter": - return { dc: rule.datacenter }; + return { dc: scope.datacenter }; case "rack": - return { dc: rule.datacenter, rack: rule.rack }; + return { dc: scope.datacenter, rack: scope.rack }; } } -function routingRuleLabel(rule: RoutingRule): string { - switch (rule.kind) { +function routingScopeLabel(scope: AlternatorRoutingScope): string { + switch (scope.kind) { case "cluster": return "Cluster()"; case "datacenter": - return `Datacenter(dc=${rule.datacenter})`; + return `Datacenter(dc=${scope.datacenter})`; case "rack": - return `Rack(dc=${rule.datacenter}, rack=${rule.rack})`; + return `Rack(dc=${scope.datacenter}, rack=${scope.rack})`; } } diff --git a/src/document.ts b/src/document.ts index 29a6fce..1a045ed 100644 --- a/src/document.ts +++ b/src/document.ts @@ -1,4 +1,4 @@ -import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import type { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient, type TranslateConfig, @@ -6,32 +6,28 @@ import { import { AlternatorDynamoDBClient } from "./client.js"; import type { AlternatorDynamoDBClientConfig } from "./types.js"; -type InternalDocumentClientConstructor = new ( - client: DynamoDBClient, - translateConfig?: TranslateConfig, -) => AlternatorDynamoDBDocumentClient; - export class AlternatorDynamoDBDocumentClient extends DynamoDBDocumentClient { - private readonly ownedClient: AlternatorDynamoDBClient | undefined; - - constructor(config: AlternatorDynamoDBClientConfig, translateConfig?: TranslateConfig); - constructor(configOrClient: AlternatorDynamoDBClientConfig | DynamoDBClient, translateConfig?: TranslateConfig) { - let ownedClient: AlternatorDynamoDBClient | undefined; - const client = - configOrClient instanceof DynamoDBClient - ? configOrClient - : (ownedClient = new AlternatorDynamoDBClient(configOrClient)); + private constructor( + client: DynamoDBClient, + translateConfig?: TranslateConfig, + private readonly ownedClient?: AlternatorDynamoDBClient, + ) { super(client, translateConfig); - this.ownedClient = ownedClient; } static override from( client: DynamoDBClient, translateConfig?: TranslateConfig, ): AlternatorDynamoDBDocumentClient { - const InternalDocumentClient = - AlternatorDynamoDBDocumentClient as unknown as InternalDocumentClientConstructor; - return new InternalDocumentClient(client, translateConfig); + return new AlternatorDynamoDBDocumentClient(client, translateConfig); + } + + static fromConfig( + config: AlternatorDynamoDBClientConfig, + translateConfig?: TranslateConfig, + ): AlternatorDynamoDBDocumentClient { + const client = new AlternatorDynamoDBClient(config); + return new AlternatorDynamoDBDocumentClient(client, translateConfig, client); } override destroy(): void { diff --git a/src/index.ts b/src/index.ts index 44b4dce..174fc0e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,23 +1,25 @@ export { AlternatorDynamoDBClient } from "./client.js"; export { routing } from "./routing.js"; -export { - ResponseCompressionDeflate, - ResponseCompressionGzip, -} from "./types.js"; +export type { AlternatorDynamoDBClientApi } from "./client.js"; export type { AlternatorCompressionOptions, AlternatorConnectionOptions, + AlternatorConnectionTimeoutOptions, AlternatorDiscoveryOptions, AlternatorDynamoDBClientConfig, + AlternatorEdgeConnectionOptions, + AlternatorEdgeDynamoDBClientConfig, AlternatorHeaderOptimizationOptions, + AlternatorKeyRouteAffinityConfig, + AlternatorKeyRouteAffinityMode, AlternatorKeyRouteAffinityOptions, - AlternatorKeyRouteAffinityType, AlternatorLogger, + AlternatorNodeConnectionOptions, + AlternatorNodeDynamoDBClientConfig, AlternatorNode, AlternatorPartitionKeyByTable, AlternatorRequestCompressionConfig, AlternatorRequestCompressionOptions, - AlternatorResponseCompression, AlternatorResponseCompressionAlgorithm, AlternatorResponseCompressionConfig, AlternatorResponseCompressionOptions, @@ -26,16 +28,19 @@ export type { AlternatorRequestCompressor, AlternatorRequestCompressorResult, AlternatorTlsOptions, + AlternatorTlsMaterial, AlternatorUserAgentConfig, AlternatorUserAgentOptions, AlternatorUserAgentTransformer, - ResponseCompression, + NonEmptyReadonlyArray, } from "./types.js"; export type { - ClusterRoutingRule, - DatacenterRoutingRule, - RackRoutingRule, - RoutingFallbackOptions, - RoutingKind, - RoutingRule, + AlternatorClusterRoutingScope, + AlternatorDatacenterRoutingScope, + AlternatorDatacenterRoutingScopeOptions, + AlternatorRackRoutingScope, + AlternatorRackRoutingScopeOptions, + AlternatorRoutingFallbackOptions, + AlternatorRoutingScope, + AlternatorRoutingScopeKind, } from "./routing.js"; diff --git a/src/routing.ts b/src/routing.ts index 814d4c7..64a1d23 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -1,27 +1,39 @@ -export type RoutingKind = "cluster" | "datacenter" | "rack"; +export type AlternatorRoutingScopeKind = "cluster" | "datacenter" | "rack"; -export interface RoutingFallbackOptions { - fallback?: RoutingRule; +export interface AlternatorRoutingFallbackOptions { + fallback?: AlternatorRoutingScope; } -export interface ClusterRoutingRule { +export interface AlternatorClusterRoutingScope { readonly kind: "cluster"; } -export interface DatacenterRoutingRule { +export interface AlternatorDatacenterRoutingScope { readonly kind: "datacenter"; readonly datacenter: string; - readonly fallback?: RoutingRule; + readonly fallback?: AlternatorRoutingScope; } -export interface RackRoutingRule { +export interface AlternatorRackRoutingScope { readonly kind: "rack"; readonly datacenter: string; readonly rack: string; - readonly fallback?: RoutingRule; + readonly fallback?: AlternatorRoutingScope; } -export type RoutingRule = ClusterRoutingRule | DatacenterRoutingRule | RackRoutingRule; +export type AlternatorRoutingScope = + | AlternatorClusterRoutingScope + | AlternatorDatacenterRoutingScope + | AlternatorRackRoutingScope; + +export interface AlternatorDatacenterRoutingScopeOptions extends AlternatorRoutingFallbackOptions { + readonly datacenter: string; +} + +export interface AlternatorRackRoutingScopeOptions extends AlternatorRoutingFallbackOptions { + readonly datacenter: string; + readonly rack: string; +} export interface LocalNodesQuery { readonly dc?: string; @@ -34,30 +46,26 @@ function assertName(name: string, label: string): void { } } -function cluster(): ClusterRoutingRule { +function cluster(): AlternatorClusterRoutingScope { return { kind: "cluster" }; } -function datacenter(datacenterName: string, options: RoutingFallbackOptions = {}): DatacenterRoutingRule { - assertName(datacenterName, "datacenter"); +function datacenter(options: AlternatorDatacenterRoutingScopeOptions): AlternatorDatacenterRoutingScope { + assertName(options.datacenter, "datacenter"); return { kind: "datacenter", - datacenter: datacenterName, + datacenter: options.datacenter, ...(options.fallback ? { fallback: options.fallback } : {}), }; } -function rack( - datacenterName: string, - rackName: string, - options: RoutingFallbackOptions = {}, -): RackRoutingRule { - assertName(datacenterName, "datacenter"); - assertName(rackName, "rack"); +function rack(options: AlternatorRackRoutingScopeOptions): AlternatorRackRoutingScope { + assertName(options.datacenter, "datacenter"); + assertName(options.rack, "rack"); return { kind: "rack", - datacenter: datacenterName, - rack: rackName, + datacenter: options.datacenter, + rack: options.rack, ...(options.fallback ? { fallback: options.fallback } : {}), }; } @@ -68,24 +76,24 @@ export const routing = { rack, }; -export function routingQueries(rule: RoutingRule): LocalNodesQuery[] { - const query = queryForRule(rule); - const fallback = "fallback" in rule ? rule.fallback : undefined; +export function routingQueries(scope: AlternatorRoutingScope): LocalNodesQuery[] { + const query = queryForScope(scope); + const fallback = "fallback" in scope ? scope.fallback : undefined; return fallback ? [query, ...routingQueries(fallback)] : [query]; } -export function routingChain(rule: RoutingRule): RoutingRule[] { - const fallback = "fallback" in rule ? rule.fallback : undefined; - return fallback ? [rule, ...routingChain(fallback)] : [rule]; +export function routingChain(scope: AlternatorRoutingScope): AlternatorRoutingScope[] { + const fallback = "fallback" in scope ? scope.fallback : undefined; + return fallback ? [scope, ...routingChain(fallback)] : [scope]; } -function queryForRule(rule: RoutingRule): LocalNodesQuery { - switch (rule.kind) { +function queryForScope(scope: AlternatorRoutingScope): LocalNodesQuery { + switch (scope.kind) { case "cluster": return {}; case "datacenter": - return { dc: rule.datacenter }; + return { dc: scope.datacenter }; case "rack": - return { dc: rule.datacenter, rack: rule.rack }; + return { dc: scope.datacenter, rack: scope.rack }; } } diff --git a/src/runtime.ts b/src/runtime.ts index 2057eff..aed5cb8 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -6,7 +6,11 @@ import type { } from "@smithy/types"; import type { HttpHandler, HttpHandlerUserInput, HttpRequest, HttpResponse } from "@smithy/protocol-http"; import { decompressResponse } from "./compression.js"; -import type { AlternatorDynamoDBClientConfig, NormalizedAlternatorConfig } from "./types.js"; +import type { + AlternatorDynamoDBClientConfig, + AlternatorTlsMaterial, + NormalizedAlternatorConfig, +} from "./types.js"; type Handler = HttpHandler; type GenericHttpHandler = HttpHandler>; @@ -21,13 +25,16 @@ export function assertRuntimeSupport(config: NormalizedAlternatorConfig): void { } const connection = config.connection; - if (connection?.maxSockets !== undefined) { + if (connection && "maxSockets" in connection && connection.maxSockets !== undefined) { throw new Error("Alternator edge runtime does not support socket pool tuning"); } - if (connection?.socketTimeoutMs !== undefined || connection?.connectionTimeoutMs !== undefined) { + if ( + (connection?.timeouts && "socketMs" in connection.timeouts && connection.timeouts.socketMs !== undefined) || + (connection?.timeouts && "connectMs" in connection.timeouts && connection.timeouts.connectMs !== undefined) + ) { throw new Error("Alternator edge runtime does not support Node socket timeout options"); } - if (connection?.node !== undefined) { + if (connection && "node" in connection && connection.node !== undefined) { throw new Error("Alternator edge runtime does not support Node HTTP handler options"); } @@ -50,10 +57,10 @@ export function createRequestHandler( if (config.runtime === "edge") { const fetchOptions = { - ...config.connection?.fetch, + ...(config.connection && "fetch" in config.connection ? config.connection.fetch : undefined), }; - if (config.connection?.requestTimeoutMs !== undefined) { - fetchOptions.requestTimeout = config.connection.requestTimeoutMs; + if (config.connection?.timeouts?.requestMs !== undefined) { + fetchOptions.requestTimeout = config.connection.timeouts.requestMs; } if (config.connection?.keepAlive !== undefined) { fetchOptions.keepAlive = config.connection.keepAlive; @@ -182,20 +189,20 @@ async function buildNodeHandlerOptions( const connection = config.connection; const tls = config.tls; const keepAlive = connection?.keepAlive ?? true; - const maxSockets = connection?.maxSockets ?? 50; + const maxSockets = connection && "maxSockets" in connection ? connection.maxSockets ?? 50 : 50; const httpAgent: Record = { keepAlive, maxSockets }; const httpsAgent: Record = { keepAlive, maxSockets }; if (tls) { if (tls.ca !== undefined) { - httpsAgent.ca = tls.ca; + httpsAgent.ca = await tlsMaterialValue(tls.ca); } if (tls.cert !== undefined) { - httpsAgent.cert = tls.cert; + httpsAgent.cert = await tlsMaterialValue(tls.cert); } if (tls.key !== undefined) { - httpsAgent.key = tls.key; + httpsAgent.key = await tlsMaterialValue(tls.key); } if (tls.rejectUnauthorized !== undefined) { httpsAgent.rejectUnauthorized = tls.rejectUnauthorized; @@ -204,38 +211,37 @@ async function buildNodeHandlerOptions( httpsAgent.maxCachedSessions = 0; } - if (tls.caFile || tls.certFile || tls.keyFile) { - const fs = await import("node:fs/promises"); - if (tls.caFile) { - httpsAgent.ca = await fs.readFile(tls.caFile); - } - if (tls.certFile) { - httpsAgent.cert = await fs.readFile(tls.certFile); - } - if (tls.keyFile) { - httpsAgent.key = await fs.readFile(tls.keyFile); - } - } } const options: NodeHttpHandlerOptions = { httpAgent, httpsAgent, - ...connection?.node, + ...(connection && "node" in connection ? connection.node : undefined), }; - if (connection?.requestTimeoutMs !== undefined) { - options.requestTimeout = connection.requestTimeoutMs; + if (connection?.timeouts?.requestMs !== undefined) { + options.requestTimeout = connection.timeouts.requestMs; } - if (connection?.connectionTimeoutMs !== undefined) { - options.connectionTimeout = connection.connectionTimeoutMs; + if (connection?.timeouts && "connectMs" in connection.timeouts && connection.timeouts.connectMs !== undefined) { + options.connectionTimeout = connection.timeouts.connectMs; } - if (connection?.socketTimeoutMs !== undefined) { - options.socketTimeout = connection.socketTimeoutMs; + if (connection?.timeouts && "socketMs" in connection.timeouts && connection.timeouts.socketMs !== undefined) { + options.socketTimeout = connection.timeouts.socketMs; } - if (connection?.throwOnRequestTimeout !== undefined) { + if (connection && "throwOnRequestTimeout" in connection && connection.throwOnRequestTimeout !== undefined) { options.throwOnRequestTimeout = connection.throwOnRequestTimeout; } return options; } + +async function tlsMaterialValue(material: AlternatorTlsMaterial): Promise { + if ("file" in material) { + const fs = await import("node:fs/promises"); + return fs.readFile(material.file); + } + if ("bytes" in material) { + return material.bytes; + } + return material.text; +} diff --git a/src/types.ts b/src/types.ts index 385d2e2..7f36a37 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,12 @@ import type { DynamoDBClientConfig } from "@aws-sdk/client-dynamodb"; import type { FetchHttpHandlerOptions, NodeHttpHandlerOptions } from "@smithy/types"; -import type { RoutingRule } from "./routing.js"; +import type { AlternatorRoutingScope } from "./routing.js"; export type AlternatorScheme = "http" | "https"; export type AlternatorRuntime = "node" | "edge"; -export type AlternatorKeyRouteAffinityType = "none" | "read-before-write" | "any-write"; +export type AlternatorKeyRouteAffinityMode = "read-before-write" | "any-write"; export type AlternatorUserAgentTransformer = (userAgent: string) => string | null | undefined; +export type NonEmptyReadonlyArray = readonly [T, ...T[]]; export interface AlternatorLogger { debug?: (...content: unknown[]) => void; @@ -22,30 +23,19 @@ export interface AlternatorNode { } export interface AlternatorRequestCompressionOptions { - enabled?: boolean; thresholdBytes?: number; gzipLevel?: number; compressor?: AlternatorRequestCompressor; } -export type AlternatorRequestCompressionConfig = boolean | AlternatorRequestCompressionOptions; - -export const ResponseCompressionGzip = "gzip" as const; -export const ResponseCompressionDeflate = "deflate" as const; - -export type AlternatorResponseCompressionAlgorithm = - | typeof ResponseCompressionGzip - | typeof ResponseCompressionDeflate; - -export type AlternatorResponseCompression = AlternatorResponseCompressionAlgorithm; -export type ResponseCompression = AlternatorResponseCompressionAlgorithm; +export type AlternatorRequestCompressionConfig = false | AlternatorRequestCompressionOptions; +export type AlternatorResponseCompressionAlgorithm = "gzip" | "deflate"; export interface AlternatorResponseCompressionOptions { - enabled?: boolean; algorithms?: readonly AlternatorResponseCompressionAlgorithm[]; } -export type AlternatorResponseCompressionConfig = boolean | AlternatorResponseCompressionOptions; +export type AlternatorResponseCompressionConfig = false | AlternatorResponseCompressionOptions; export interface AlternatorCompressionOptions { request?: AlternatorRequestCompressionConfig; @@ -63,42 +53,37 @@ export type AlternatorRequestCompressor = ( ) => AlternatorRequestCompressorResult | Promise; export interface AlternatorHeaderOptimizationOptions { - enabled?: boolean; allowedHeaders?: readonly string[]; - /** - * @deprecated Use allowedHeaders. Header optimization uses a whitelist, not a strip-list. - */ - stripHeaders?: readonly string[]; + additionalAllowedHeaders?: readonly string[]; } export interface AlternatorUserAgentOptions { - enabled?: boolean; value?: string; + append?: string; transform?: AlternatorUserAgentTransformer; } -export type AlternatorUserAgentConfig = - | boolean - | string - | AlternatorUserAgentTransformer - | AlternatorUserAgentOptions; +export type AlternatorUserAgentConfig = false | AlternatorUserAgentOptions; -export type AlternatorPartitionKeyByTable = Record; +export type AlternatorPartitionKeyByTable = Record | ReadonlyMap; export interface AlternatorKeyRouteAffinityOptions { - enabled?: boolean; - type?: AlternatorKeyRouteAffinityType; + mode?: AlternatorKeyRouteAffinityMode; partitionKeys?: AlternatorPartitionKeyByTable; autoDiscoverPartitionKeys?: boolean; } +export type AlternatorKeyRouteAffinityConfig = false | AlternatorKeyRouteAffinityOptions; + +export type AlternatorTlsMaterial = + | { readonly text: string } + | { readonly bytes: Uint8Array } + | { readonly file: string }; + export interface AlternatorTlsOptions { - ca?: string | Uint8Array; - caFile?: string; - cert?: string | Uint8Array; - certFile?: string; - key?: string | Uint8Array; - keyFile?: string; + ca?: AlternatorTlsMaterial; + cert?: AlternatorTlsMaterial; + key?: AlternatorTlsMaterial; rejectUnauthorized?: boolean; sessionCache?: boolean; } @@ -110,41 +95,67 @@ export interface AlternatorDiscoveryOptions { timeoutMs?: number; } -export interface AlternatorConnectionOptions { +export interface AlternatorConnectionTimeoutOptions { + connectMs?: number; + requestMs?: number; + socketMs?: number; +} + +export interface AlternatorNodeConnectionOptions { keepAlive?: boolean; maxSockets?: number; - connectionTimeoutMs?: number; - requestTimeoutMs?: number; - socketTimeoutMs?: number; + timeouts?: AlternatorConnectionTimeoutOptions; throwOnRequestTimeout?: boolean; node?: Omit; +} + +export interface AlternatorEdgeConnectionOptions { + keepAlive?: boolean; + timeouts?: Pick; fetch?: FetchHttpHandlerOptions; } +export type AlternatorConnectionOptions = + | AlternatorNodeConnectionOptions + | AlternatorEdgeConnectionOptions; + type BaseDynamoDBClientConfig = Omit< DynamoDBClientConfig, - "credentials" | "endpoint" | "region" | "requestHandler" | "runtime" | "tls" + "credentials" | "endpoint" | "logger" | "region" | "requestHandler" | "runtime" | "tls" >; -export interface AlternatorDynamoDBClientConfig extends BaseDynamoDBClientConfig { - seeds: readonly string[]; +interface BaseAlternatorDynamoDBClientConfig extends BaseDynamoDBClientConfig { + seeds: NonEmptyReadonlyArray; scheme?: AlternatorScheme; port?: number; - routing?: RoutingRule; + routing?: AlternatorRoutingScope; region?: DynamoDBClientConfig["region"]; credentials?: DynamoDBClientConfig["credentials"]; - runtime?: AlternatorRuntime; requestHandler?: DynamoDBClientConfig["requestHandler"]; compression?: AlternatorCompressionOptions; headerOptimization?: boolean | AlternatorHeaderOptimizationOptions; userAgent?: AlternatorUserAgentConfig; - keyRouteAffinity?: boolean | AlternatorKeyRouteAffinityOptions; - tls?: AlternatorTlsOptions; + keyRouteAffinity?: AlternatorKeyRouteAffinityConfig; discovery?: AlternatorDiscoveryOptions; - connection?: AlternatorConnectionOptions; - endpoint?: never; + logger?: AlternatorLogger; } +export interface AlternatorNodeDynamoDBClientConfig extends BaseAlternatorDynamoDBClientConfig { + runtime?: "node"; + tls?: AlternatorTlsOptions; + connection?: AlternatorNodeConnectionOptions; +} + +export interface AlternatorEdgeDynamoDBClientConfig extends BaseAlternatorDynamoDBClientConfig { + runtime: "edge"; + tls?: never; + connection?: AlternatorEdgeConnectionOptions; +} + +export type AlternatorDynamoDBClientConfig = + | AlternatorNodeDynamoDBClientConfig + | AlternatorEdgeDynamoDBClientConfig; + export interface NormalizedRequestCompressionOptions { readonly enabled: boolean; readonly thresholdBytes: number; @@ -173,7 +184,7 @@ export interface NormalizedUserAgentOptions { export interface NormalizedKeyRouteAffinityOptions { readonly enabled: boolean; - readonly type: AlternatorKeyRouteAffinityType; + readonly mode: AlternatorKeyRouteAffinityMode; readonly partitionKeys: ReadonlyMap; readonly autoDiscoverPartitionKeys: boolean; } @@ -189,7 +200,7 @@ export interface NormalizedAlternatorConfig { readonly seeds: readonly string[]; readonly scheme: AlternatorScheme; readonly port: number; - readonly routing: RoutingRule; + readonly routing: AlternatorRoutingScope; readonly runtime: AlternatorRuntime; readonly compression: NormalizedCompressionOptions; readonly headerOptimization: NormalizedHeaderOptimizationOptions; diff --git a/src/user-agent.ts b/src/user-agent.ts index 6fdd536..f22dd00 100644 --- a/src/user-agent.ts +++ b/src/user-agent.ts @@ -1,6 +1,7 @@ import packageJson from "../package.json" with { type: "json" }; import type { AlternatorUserAgentConfig, + AlternatorUserAgentOptions, AlternatorUserAgentTransformer, NormalizedUserAgentOptions, } from "./types.js"; @@ -17,29 +18,35 @@ export function normalizeUserAgent(input: AlternatorUserAgentConfig | undefined) if (input === false) { return {}; } - if (input === undefined || input === true) { + if (input === undefined) { return { value: defaultUserAgent }; } - if (typeof input === "string") { - requireValidUserAgent(input, "userAgent"); - return { value: input }; + if (!isRecord(input)) { + throw new TypeError("userAgent must be false or an options object"); } - if (typeof input === "function") { - return normalizeTransformedUserAgent(input, defaultUserAgent); - } - if (input.enabled === false) { - return {}; + const options = input as AlternatorUserAgentOptions; + + const configuredFields = [ + options.value !== undefined, + options.append !== undefined, + options.transform !== undefined, + ].filter(Boolean).length; + if (configuredFields > 1) { + throw new TypeError("userAgent.value, userAgent.append, and userAgent.transform are mutually exclusive"); } - if (input.value !== undefined && input.transform !== undefined) { - throw new TypeError("userAgent.value and userAgent.transform cannot both be set"); + + if (options.value !== undefined) { + requireValidUserAgent(options.value, "userAgent.value"); + return { value: options.value }; } - if (input.value !== undefined) { - requireValidUserAgent(input.value, "userAgent.value"); - return { value: input.value }; + if (options.append !== undefined) { + requireValidUserAgent(options.append, "userAgent.append"); + return { value: `${defaultUserAgent} ${options.append}` }; } - if (input.transform !== undefined) { - return normalizeTransformedUserAgent(input.transform, defaultUserAgent); + if (options.transform !== undefined) { + return normalizeTransformedUserAgent(options.transform, defaultUserAgent); } + return { value: defaultUserAgent }; } @@ -69,6 +76,10 @@ export function applyUserAgent( return nextHeaders; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + function normalizeTransformedUserAgent( transform: AlternatorUserAgentTransformer, defaultUserAgent: string, diff --git a/test/affinity.test.ts b/test/affinity.test.ts index bc44e36..1fe8eac 100644 --- a/test/affinity.test.ts +++ b/test/affinity.test.ts @@ -25,12 +25,12 @@ describe("key route affinity", () => { requestHandler: handler, discovery: { background: false }, keyRouteAffinity: { - type: "read-before-write", + mode: "read-before-write", partitionKeys: { users: "id" }, }, }); - await client.refreshLiveNodes(); + await client.alternator.refreshNodes(); await client.send( new PutItemCommand({ TableName: "users", @@ -62,12 +62,12 @@ describe("key route affinity", () => { requestHandler: handler, discovery: { background: false }, keyRouteAffinity: { - type: "any-write", + mode: "any-write", partitionKeys: { users: "id" }, }, }); - await client.refreshLiveNodes(); + await client.alternator.refreshNodes(); await client.send( new BatchWriteItemCommand({ RequestItems: { diff --git a/test/config.test.ts b/test/config.test.ts index a246cbd..316d471 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -1,15 +1,12 @@ import { ListTablesCommand } from "@aws-sdk/client-dynamodb"; import { describe, expect, it } from "vitest"; -import { - AlternatorDynamoDBClient, - ResponseCompressionDeflate, - ResponseCompressionGzip, -} from "../src/index.js"; +import { AlternatorDynamoDBClient } from "../src/index.js"; +import { normalizeConfig } from "../src/config.js"; import { RecordingHandler } from "./helpers.js"; describe("AlternatorDynamoDBClient config", () => { it("requires seeds and rejects AWS endpoint", () => { - expect(() => new AlternatorDynamoDBClient({ seeds: [] })).toThrow(/seeds/); + expect(() => new AlternatorDynamoDBClient({ seeds: [] as never })).toThrow(/seeds/); expect( () => new AlternatorDynamoDBClient({ @@ -27,7 +24,7 @@ describe("AlternatorDynamoDBClient config", () => { expect(() => new AlternatorDynamoDBClient({ seeds: ["localhost"], scheme: "ftp" as never })).toThrow(/scheme/); expect(() => new AlternatorDynamoDBClient({ seeds: ["localhost"], port: 0 })).toThrow(/port/); - expect(new AlternatorDynamoDBClient({ seeds: ["[::1]"] }).getLiveNodes()[0]?.url).toBe("http://[::1]:8080"); + expect(new AlternatorDynamoDBClient({ seeds: ["[::1]"] }).alternator.nodes()[0]?.url).toBe("http://[::1]:8080"); }); it("uses Alternator defaults for URL and signing region", async () => { @@ -38,7 +35,7 @@ describe("AlternatorDynamoDBClient config", () => { discovery: { background: false }, }); - expect(client.getLiveNodes()).toEqual([ + expect(client.alternator.nodes()).toEqual([ { host: "localhost", scheme: "http", @@ -50,7 +47,7 @@ describe("AlternatorDynamoDBClient config", () => { await client.send(new ListTablesCommand({})); expect(handler.requests.at(-1)?.hostname).toBe("localhost"); - expect(client.alternatorConfig.compression).toEqual({ + expect(normalizeConfig({ seeds: ["localhost"] }).compression).toEqual({ request: { enabled: false, thresholdBytes: 0, @@ -76,8 +73,8 @@ describe("AlternatorDynamoDBClient config", () => { new AlternatorDynamoDBClient({ seeds: ["localhost"], runtime: "edge", - tls: { caFile: "/tmp/ca.pem" }, - }), + tls: { ca: { file: "/tmp/ca.pem" } }, + } as never), ).toThrow(/edge runtime.*TLS/); expect( @@ -86,7 +83,7 @@ describe("AlternatorDynamoDBClient config", () => { seeds: ["localhost"], runtime: "edge", connection: { maxSockets: 8 }, - }), + } as never), ).toThrow(/socket pool/); }); @@ -95,7 +92,7 @@ describe("AlternatorDynamoDBClient config", () => { () => new AlternatorDynamoDBClient({ seeds: ["localhost"], - userAgent: "", + userAgent: "" as never, }), ).toThrow(/userAgent/); @@ -108,7 +105,7 @@ describe("AlternatorDynamoDBClient config", () => { transform: (userAgent) => `${userAgent} app/1`, }, }), - ).toThrow(/cannot both be set/); + ).toThrow(/mutually exclusive/); }); it("rejects direct Node agent overrides in connection.node", () => { @@ -170,7 +167,7 @@ describe("AlternatorDynamoDBClient config", () => { }, }, }); - expect(client.getPartitionKeyName("users")).toBe("id"); + expect(client.alternator.partitionKey("users")).toBe("id"); }); it("validates routing objects supplied from JavaScript", () => { @@ -218,7 +215,6 @@ describe("AlternatorDynamoDBClient config", () => { seeds: ["localhost"], compression: { request: { - enabled: true, gzipLevel: -2, }, }, @@ -237,65 +233,66 @@ describe("AlternatorDynamoDBClient config", () => { ).toThrow(/compression\.enabled/); expect( - new AlternatorDynamoDBClient({ + normalizeConfig({ seeds: ["localhost"], compression: { request: { - enabled: true, gzipLevel: -1, }, }, - }).alternatorConfig.compression.request.gzipLevel, + }).compression.request.gzipLevel, ).toBe(-1); }); it("normalizes and validates response compression algorithms", () => { expect( - new AlternatorDynamoDBClient({ + normalizeConfig({ seeds: ["localhost"], - compression: { response: true }, - }).alternatorConfig.compression.response, + compression: { response: {} }, + }).compression.response, ).toEqual({ enabled: true, - algorithms: [ResponseCompressionGzip], + algorithms: ["gzip"], }); expect( - new AlternatorDynamoDBClient({ + normalizeConfig({ seeds: ["localhost"], compression: { response: { - enabled: true, algorithms: [ - ResponseCompressionDeflate, - ResponseCompressionDeflate, - ResponseCompressionGzip, + "deflate", + "deflate", + "gzip", ], }, }, - }).alternatorConfig.compression.response, + }).compression.response, ).toEqual({ enabled: true, - algorithms: [ResponseCompressionDeflate, ResponseCompressionGzip], + algorithms: ["deflate", "gzip"], }); expect( - new AlternatorDynamoDBClient({ + normalizeConfig({ seeds: ["localhost"], compression: { response: false }, - }).alternatorConfig.compression.response.enabled, + }).compression.response.enabled, ).toBe(false); expect( - new AlternatorDynamoDBClient({ + normalizeConfig({ seeds: ["localhost"], compression: { response: { - algorithms: [ResponseCompressionGzip], + algorithms: ["gzip"], }, }, - }).alternatorConfig.compression.response.enabled, - ).toBe(false); + }).compression.response, + ).toEqual({ + enabled: true, + algorithms: ["gzip"], + }); expect( () => @@ -303,7 +300,6 @@ describe("AlternatorDynamoDBClient config", () => { seeds: ["localhost"], compression: { response: { - enabled: true, algorithms: ["br"], }, } as never, @@ -311,26 +307,26 @@ describe("AlternatorDynamoDBClient config", () => { ).toThrow(/compression\.response/); }); - it("validates key route affinity type", () => { - for (const type of ["bad", "", 42]) { + it("validates key route affinity mode", () => { + for (const mode of ["bad", "", 42]) { expect( () => new AlternatorDynamoDBClient({ seeds: ["localhost"], keyRouteAffinity: { - type, + mode, } as never, }), - ).toThrow(/keyRouteAffinity\.type/); + ).toThrow(/keyRouteAffinity\.mode/); } expect( - new AlternatorDynamoDBClient({ + normalizeConfig({ seeds: ["localhost"], keyRouteAffinity: { - type: "read-before-write", + mode: "read-before-write", }, - }).alternatorConfig.keyRouteAffinity.type, + }).keyRouteAffinity.mode, ).toBe("read-before-write"); }); }); diff --git a/test/discovery.test.ts b/test/discovery.test.ts index 5816301..6888d9f 100644 --- a/test/discovery.test.ts +++ b/test/discovery.test.ts @@ -20,7 +20,7 @@ describe("Alternator discovery", () => { discovery: { background: false }, }); - await expect(client.refreshLiveNodes()).resolves.toEqual([ + await expect(client.alternator.refreshNodes()).resolves.toEqual([ { host: "node-a.internal", scheme: "http", @@ -34,7 +34,7 @@ describe("Alternator discovery", () => { url: "http://node-b.internal:8080", }, ]); - expect(client.getLiveNodes().map((node) => node.host)).toEqual([ + expect(client.alternator.nodes().map((node) => node.host)).toEqual([ "node-a.internal", "node-b.internal", ]); @@ -60,15 +60,15 @@ describe("Alternator discovery", () => { routing: routing.cluster(), }); - await client.refreshLiveNodes(); + await client.alternator.refreshNodes(); - expect(client.getLiveNodes().map((node) => node.host)).toEqual([ + expect(client.alternator.nodes().map((node) => node.host)).toEqual([ "dc1-a.internal", "dc1-b.internal", "dc2-a.internal", "dc2-b.internal", ]); - await client.refreshLiveNodes(); + await client.alternator.refreshNodes(); expect(handler.requests.map((request) => request.hostname)).toEqual([ "seed-dc1.internal", @@ -98,14 +98,17 @@ describe("Alternator discovery", () => { seeds: ["seed"], requestHandler: handler, discovery: { background: false }, - routing: routing.rack("dc1", "rack1", { - fallback: routing.datacenter("dc1", { + routing: routing.rack({ + datacenter: "dc1", + rack: "rack1", + fallback: routing.datacenter({ + datacenter: "dc1", fallback: routing.cluster(), }), }), }); - await client.refreshLiveNodes(); + await client.alternator.refreshNodes(); expect(seenQueries).toEqual([ missingDatacenterQuery, @@ -113,7 +116,7 @@ describe("Alternator discovery", () => { { dc: "dc1", rack: "rack1" }, { dc: "dc1" }, ]); - expect(client.getLiveNodes().map((node) => node.host)).toEqual(["dc-node"]); + expect(client.alternator.nodes().map((node) => node.host)).toEqual(["dc-node"]); }); it("detects rack/datacenter query support", async () => { @@ -122,14 +125,14 @@ describe("Alternator discovery", () => { requestHandler: new RecordingHandler((request) => (request.query.dc || request.query.rack ? [] : ["seed"])), discovery: { background: false }, }); - await expect(supported.checkRackDatacenterSupport()).resolves.toBe(true); + await expect(supported.alternator.supportsScopedDiscovery()).resolves.toBe(true); const unsupported = new AlternatorDynamoDBClient({ seeds: ["seed"], requestHandler: new RecordingHandler(() => ["seed"]), discovery: { background: false }, }); - await expect(unsupported.checkRackDatacenterSupport()).resolves.toBe(false); + await expect(unsupported.alternator.supportsScopedDiscovery()).resolves.toBe(false); }); it("falls back instead of accepting scoped nodes when rack/datacenter filters are unsupported", async () => { @@ -145,20 +148,23 @@ describe("Alternator discovery", () => { seeds: ["seed"], requestHandler: handler, discovery: { background: false }, - routing: routing.rack("dc1", "rack1", { - fallback: routing.datacenter("dc1", { + routing: routing.rack({ + datacenter: "dc1", + rack: "rack1", + fallback: routing.datacenter({ + datacenter: "dc1", fallback: routing.cluster(), }), }), }); - await client.refreshLiveNodes(); + await client.alternator.refreshNodes(); expect(seenQueries).toEqual([ missingDatacenterQuery, {}, ]); - expect(client.getLiveNodes().map((node) => node.host)).toEqual(["cluster-node"]); + expect(client.alternator.nodes().map((node) => node.host)).toEqual(["cluster-node"]); }); it("falls back from rack scope to datacenter scope when only rack filters are unsupported", async () => { @@ -183,21 +189,24 @@ describe("Alternator discovery", () => { seeds: ["seed"], requestHandler: handler, discovery: { background: false }, - routing: routing.rack("dc1", "rack1", { - fallback: routing.datacenter("dc1", { + routing: routing.rack({ + datacenter: "dc1", + rack: "rack1", + fallback: routing.datacenter({ + datacenter: "dc1", fallback: routing.cluster(), }), }), }); - await client.refreshLiveNodes(); + await client.alternator.refreshNodes(); expect(seenQueries).toEqual([ missingDatacenterQuery, missingRackQuery, { dc: "dc1" }, ]); - expect(client.getLiveNodes().map((node) => node.host)).toEqual(["dc-node"]); + expect(client.alternator.nodes().map((node) => node.host)).toEqual(["dc-node"]); }); it("validates configured rack/datacenter scopes", async () => { @@ -210,19 +219,21 @@ describe("Alternator discovery", () => { return []; }), discovery: { background: false }, - routing: routing.datacenter("dc1"), + routing: routing.datacenter({ datacenter: "dc1" }), }); - await expect(valid.checkIfRackAndDatacenterSetCorrectly()).resolves.toBeUndefined(); + await expect(valid.alternator.validateRouting()).resolves.toBeUndefined(); const invalid = new AlternatorDynamoDBClient({ seeds: ["seed"], requestHandler: new RecordingHandler(() => []), discovery: { background: false }, - routing: routing.rack("dc1", "rack1", { - fallback: routing.datacenter("dc1"), + routing: routing.rack({ + datacenter: "dc1", + rack: "rack1", + fallback: routing.datacenter({ datacenter: "dc1" }), }), }); - await expect(invalid.validateRackDatacenterConfig()).rejects.toThrow(/has no nodes/); + await expect(invalid.alternator.validateRouting()).rejects.toThrow(/has no nodes/); }); it("rejects rack/datacenter validation when scope filters are unsupported", async () => { @@ -230,12 +241,13 @@ describe("Alternator discovery", () => { seeds: ["seed"], requestHandler: new RecordingHandler(() => ["seed"]), discovery: { background: false }, - routing: routing.datacenter("dc1", { + routing: routing.datacenter({ + datacenter: "dc1", fallback: routing.cluster(), }), }); - await expect(client.validateRackDatacenterConfig()).rejects.toThrow(/does not support datacenter/); + await expect(client.alternator.validateRouting()).rejects.toThrow(/does not support datacenter/); }); it("uses request-triggered discovery in edge runtime", async () => { diff --git a/test/document.test.ts b/test/document.test.ts index b799493..bb5b1df 100644 --- a/test/document.test.ts +++ b/test/document.test.ts @@ -8,7 +8,7 @@ import { commandRequests, RecordingHandler, requestBodyJson } from "./helpers.js describe("AlternatorDynamoDBDocumentClient", () => { it("constructs an Alternator low-level client and marshals document commands", async () => { const handler = new RecordingHandler(() => ({})); - const docClient = new AlternatorDynamoDBDocumentClient( + const docClient = AlternatorDynamoDBDocumentClient.fromConfig( { seeds: ["seed"], requestHandler: handler, @@ -54,7 +54,7 @@ describe("AlternatorDynamoDBDocumentClient", () => { requestHandler: handler, discovery: { background: false }, }); - await base.refreshLiveNodes(); + await base.alternator.refreshNodes(); const docClient = AlternatorDynamoDBDocumentClient.from(base); await docClient.send(new PutCommand({ TableName: "users", Item: { id: "u1" } })); @@ -81,7 +81,7 @@ describe("AlternatorDynamoDBDocumentClient", () => { await docClient.send(new PutCommand({ TableName: "users", Item: { id: "u1" } })); expect(commandRequests(handler)[0]?.hostname).toBe("aws-style.local"); - expect("getLiveNodes" in docClient).toBe(false); + expect("alternator" in docClient).toBe(false); }); it("destroys the owned low-level client and stops background discovery", async () => { @@ -92,7 +92,7 @@ describe("AlternatorDynamoDBDocumentClient", () => { } return {}; }); - const docClient = new AlternatorDynamoDBDocumentClient({ + const docClient = AlternatorDynamoDBDocumentClient.fromConfig({ seeds: ["seed"], requestHandler: handler, discovery: { diff --git a/test/integration-test/alternator-client.test.ts b/test/integration-test/alternator-client.test.ts index c006d3d..4c57f4c 100644 --- a/test/integration-test/alternator-client.test.ts +++ b/test/integration-test/alternator-client.test.ts @@ -1,10 +1,6 @@ import { GetItemCommand, ListTablesCommand, PutItemCommand } from "@aws-sdk/client-dynamodb"; import { describe, expect, it } from "vitest"; -import { - ResponseCompressionDeflate, - ResponseCompressionGzip, - routing, -} from "../../src/index.js"; +import { routing } from "../../src/index.js"; import { describeIntegration, integrationConfig, integrationEndpoints } from "./config.js"; import { buildClient, @@ -25,22 +21,26 @@ describeIntegration.each(integrationEndpoints())( routing: routing.cluster(), }); const datacenter = buildClient(endpoint, { - routing: routing.datacenter(integrationConfig.datacenter, { + routing: routing.datacenter({ + datacenter: integrationConfig.datacenter, fallback: routing.cluster(), }), }); const rack = buildClient(endpoint, { - routing: routing.rack(integrationConfig.datacenter, integrationConfig.rack, { - fallback: routing.datacenter(integrationConfig.datacenter, { + routing: routing.rack({ + datacenter: integrationConfig.datacenter, + rack: integrationConfig.rack, + fallback: routing.datacenter({ + datacenter: integrationConfig.datacenter, fallback: routing.cluster(), }), }), }); try { - await expect(cluster.refreshLiveNodes()).resolves.not.toHaveLength(0); - await expect(datacenter.refreshLiveNodes()).resolves.not.toHaveLength(0); - await expect(rack.refreshLiveNodes()).resolves.not.toHaveLength(0); + await expect(cluster.alternator.refreshNodes()).resolves.not.toHaveLength(0); + await expect(datacenter.alternator.refreshNodes()).resolves.not.toHaveLength(0); + await expect(rack.alternator.refreshNodes()).resolves.not.toHaveLength(0); } finally { cluster.destroy(); datacenter.destroy(); @@ -50,24 +50,28 @@ describeIntegration.each(integrationEndpoints())( it("falls back from a wrong datacenter or rack to a wider routing scope", async () => { const wrongDatacenter = buildClient(endpoint, { - routing: routing.datacenter("__wrong_dc__", { + routing: routing.datacenter({ + datacenter: "__wrong_dc__", fallback: routing.cluster(), }), }); const wrongRack = buildClient(endpoint, { - routing: routing.rack(integrationConfig.datacenter, "__wrong_rack__", { - fallback: routing.datacenter(integrationConfig.datacenter, { + routing: routing.rack({ + datacenter: integrationConfig.datacenter, + rack: "__wrong_rack__", + fallback: routing.datacenter({ + datacenter: integrationConfig.datacenter, fallback: routing.cluster(), }), }), }); try { - await expect(wrongDatacenter.refreshLiveNodes()).resolves.not.toHaveLength(0); - await expect(wrongDatacenter.validateRackDatacenterConfig()).resolves.toBeUndefined(); + await expect(wrongDatacenter.alternator.refreshNodes()).resolves.not.toHaveLength(0); + await expect(wrongDatacenter.alternator.validateRouting()).resolves.toBeUndefined(); - await expect(wrongRack.refreshLiveNodes()).resolves.not.toHaveLength(0); - await expect(wrongRack.validateRackDatacenterConfig()).resolves.toBeUndefined(); + await expect(wrongRack.alternator.refreshNodes()).resolves.not.toHaveLength(0); + await expect(wrongRack.alternator.validateRouting()).resolves.toBeUndefined(); } finally { wrongDatacenter.destroy(); wrongRack.destroy(); @@ -76,18 +80,19 @@ describeIntegration.each(integrationEndpoints())( it("checks rack/datacenter query support and exposes live-node APIs", async () => { const client = buildClient(endpoint, { - routing: routing.datacenter(integrationConfig.datacenter, { + routing: routing.datacenter({ + datacenter: integrationConfig.datacenter, fallback: routing.cluster(), }), }); try { - await expect(client.checkRackDatacenterSupport()).resolves.toBe(true); - await expect(client.checkIfRackAndDatacenterSetCorrectly()).resolves.toBeUndefined(); + await expect(client.alternator.supportsScopedDiscovery()).resolves.toBe(true); + await expect(client.alternator.validateRouting()).resolves.toBeUndefined(); - const nodes = await client.refreshLiveNodes(); + const nodes = await client.alternator.refreshNodes(); expect(nodes.length).toBeGreaterThan(0); - expect(client.getLiveNodes()).toEqual(nodes); + expect(client.alternator.nodes()).toEqual(nodes); } finally { client.destroy(); } @@ -95,14 +100,15 @@ describeIntegration.each(integrationEndpoints())( it("routes repeated commands through discovered nodes", async () => { const client = buildClient(endpoint, { - routing: routing.datacenter(integrationConfig.datacenter, { + routing: routing.datacenter({ + datacenter: integrationConfig.datacenter, fallback: routing.cluster(), }), }); const captured = captureCommandRequests(client); try { - const nodes = await client.refreshLiveNodes(); + const nodes = await client.alternator.refreshNodes(); expect(nodes.length).toBeGreaterThan(0); for (let index = 0; index < nodes.length * 2; index += 1) { @@ -120,14 +126,15 @@ describeIntegration.each(integrationEndpoints())( it("sends commands through discovered nodes", async () => { const client = buildClient(endpoint, { - routing: routing.datacenter(integrationConfig.datacenter, { + routing: routing.datacenter({ + datacenter: integrationConfig.datacenter, fallback: routing.cluster(), }), }); const captured = captureCommandRequests(client); try { - const nodes = await client.refreshLiveNodes(); + const nodes = await client.alternator.refreshNodes(); await client.send(new ListTablesCommand({ Limit: 1 })); await client.send(new ListTablesCommand({ Limit: 1 })); @@ -144,7 +151,6 @@ describeIntegration.each(integrationEndpoints())( const client = buildClient(endpoint, { compression: { request: { - enabled: true, thresholdBytes: 100, }, }, @@ -175,14 +181,13 @@ describeIntegration.each(integrationEndpoints())( }); it.each([ - [ResponseCompressionGzip], - [ResponseCompressionDeflate], - ])("reads %s-compressed responses", async (encoding) => { + ["gzip"], + ["deflate"], + ] as const)("reads %s-compressed responses", async (encoding) => { const tableName = uniqueTableName(`js_response_compression_${encoding}`); const client = buildClient(endpoint, { compression: { response: { - enabled: true, algorithms: [encoding], }, }, @@ -218,7 +223,6 @@ describeIntegration.each(integrationEndpoints())( it("filters wire headers using the configured whitelist", async () => { const client = buildClient(endpoint, { headerOptimization: { - enabled: true, allowedHeaders: ["Host", "X-Amz-Target", "Authorization", "X-Amz-Date"], }, }); @@ -266,7 +270,6 @@ describeIntegration.each(integrationEndpoints())( it("keeps custom-whitelisted headers when header optimization is enabled", async () => { const client = buildClient(endpoint, { headerOptimization: { - enabled: true, allowedHeaders: [ "Host", "X-Amz-Target", @@ -302,12 +305,10 @@ describeIntegration.each(integrationEndpoints())( const client = buildClient(endpoint, { compression: { request: { - enabled: true, thresholdBytes: 100, }, }, headerOptimization: { - enabled: true, allowedHeaders: [ "Host", "X-Amz-Target", diff --git a/test/integration-test/dynamodb-operations.test.ts b/test/integration-test/dynamodb-operations.test.ts index 59511fa..2bae512 100644 --- a/test/integration-test/dynamodb-operations.test.ts +++ b/test/integration-test/dynamodb-operations.test.ts @@ -105,7 +105,7 @@ describeIntegration.each(integrationEndpoints())( try { await safeDeleteTable(client, tableName); await createStringHashTable(client, tableName); - await client.refreshLiveNodes(); + await client.alternator.refreshNodes(); for (let index = 0; index < itemCount; index += 1) { await client.send( diff --git a/test/integration-test/helpers.ts b/test/integration-test/helpers.ts index fca5968..a2c9e9a 100644 --- a/test/integration-test/helpers.ts +++ b/test/integration-test/helpers.ts @@ -15,12 +15,12 @@ import { HttpRequest } from "@smithy/protocol-http"; import type { FinalizeRequestMiddleware } from "@smithy/types"; import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; -import type { AlternatorDynamoDBClientConfig } from "../../src/index.js"; +import type { AlternatorNodeDynamoDBClientConfig } from "../../src/index.js"; import { AlternatorDynamoDBClient } from "../../src/index.js"; import { AlternatorDynamoDBDocumentClient, type TranslateConfig } from "../../src/document.js"; import { integrationConfig, type IntegrationEndpoint } from "./config.js"; -type ClientOverrides = Omit, "seeds" | "scheme" | "port">; +type ClientOverrides = Omit, "seeds" | "scheme" | "port">; export interface CapturedCommandRequest { readonly commandName: string | undefined; @@ -59,7 +59,7 @@ export function buildDocumentClient( overrides: ClientOverrides = {}, translateConfig?: TranslateConfig, ): AlternatorDynamoDBDocumentClient { - return new AlternatorDynamoDBDocumentClient( + return AlternatorDynamoDBDocumentClient.fromConfig( { ...overrides, seeds: [endpoint.host], diff --git a/test/integration-test/http-client-implementation.test.ts b/test/integration-test/http-client-implementation.test.ts index 82d68ca..581c61e 100644 --- a/test/integration-test/http-client-implementation.test.ts +++ b/test/integration-test/http-client-implementation.test.ts @@ -31,7 +31,7 @@ describeIntegration.each(integrationEndpoints())( const client = buildClient(endpoint, { requestHandler: delegate }); try { - await expect(client.refreshLiveNodes()).resolves.not.toHaveLength(0); + await expect(client.alternator.refreshNodes()).resolves.not.toHaveLength(0); await expect(client.send(new ListTablesCommand({ Limit: 1 }))).resolves.toBeDefined(); expect(handledPaths).toContain("/localnodes"); diff --git a/test/integration-test/internal/connection-pool.test.ts b/test/integration-test/internal/connection-pool.test.ts index 47dec4e..8f70665 100644 --- a/test/integration-test/internal/connection-pool.test.ts +++ b/test/integration-test/internal/connection-pool.test.ts @@ -16,10 +16,10 @@ describeIntegration.each(integrationEndpoints())( try { for (let index = 0; index < 30; index += 1) { - await client.refreshLiveNodes(); + await client.alternator.refreshNodes(); } - expect(client.getLiveNodes().length).toBeGreaterThan(0); + expect(client.alternator.nodes().length).toBeGreaterThan(0); } finally { client.destroy(); } @@ -35,7 +35,7 @@ describeIntegration.each(integrationEndpoints())( }); try { - await client.refreshLiveNodes(); + await client.alternator.refreshNodes(); for (let index = 0; index < 10; index += 1) { await client.send(new ListTablesCommand({ Limit: 1 })); await sleep(250); diff --git a/test/integration-test/key-route-affinity-autodiscovery.test.ts b/test/integration-test/key-route-affinity-autodiscovery.test.ts index 4abacde..4b11fe2 100644 --- a/test/integration-test/key-route-affinity-autodiscovery.test.ts +++ b/test/integration-test/key-route-affinity-autodiscovery.test.ts @@ -17,7 +17,7 @@ describeIntegration.each(integrationEndpoints())( const tableName = uniqueTableName("js_affinity_discover_it"); const client = buildClient(endpoint, { keyRouteAffinity: { - type: "any-write", + mode: "any-write", }, }); const captured = captureCommandRequests(client); @@ -25,7 +25,7 @@ describeIntegration.each(integrationEndpoints())( try { await safeDeleteTable(client, tableName); await createStringHashTable(client, tableName, "user_id"); - const nodes = await client.refreshLiveNodes(); + const nodes = await client.alternator.refreshNodes(); await client.send( new PutItemCommand({ @@ -38,12 +38,12 @@ describeIntegration.each(integrationEndpoints())( ); await waitFor( - () => client.getPartitionKeyName(tableName) === "user_id" ? "user_id" : undefined, + () => client.alternator.partitionKey(tableName) === "user_id" ? "user_id" : undefined, "partition-key autodiscovery", ); expect(captured.some((entry) => entry.commandName === "DescribeTableCommand")).toBe(true); - expect(client.getPartitionKeyName(tableName)).toBe("user_id"); + expect(client.alternator.partitionKey(tableName)).toBe("user_id"); captured.length = 0; for (let index = 0; index < 10; index += 1) { @@ -94,7 +94,7 @@ describeIntegration.each(integrationEndpoints())( const tableName = uniqueTableName("js_affinity_preconf_it"); const client = buildClient(endpoint, { keyRouteAffinity: { - type: "any-write", + mode: "any-write", partitionKeys: { [tableName]: "user_id", }, @@ -106,7 +106,7 @@ describeIntegration.each(integrationEndpoints())( try { await safeDeleteTable(client, tableName); await createStringHashTable(client, tableName, "user_id"); - const nodes = await client.refreshLiveNodes(); + const nodes = await client.alternator.refreshNodes(); for (let index = 0; index < 10; index += 1) { await client.send( @@ -147,7 +147,7 @@ describeIntegration.each(integrationEndpoints())( const tableName = uniqueTableName("js_affinity_no_describe_it"); const client = buildClient(endpoint, { keyRouteAffinity: { - type: "any-write", + mode: "any-write", partitionKeys: { [tableName]: "user_id", }, @@ -158,7 +158,7 @@ describeIntegration.each(integrationEndpoints())( try { await safeDeleteTable(client, tableName); await createStringHashTable(client, tableName, "user_id"); - await client.refreshLiveNodes(); + await client.alternator.refreshNodes(); await client.send( new PutItemCommand({ diff --git a/test/integration-test/tls-config.test.ts b/test/integration-test/tls-config.test.ts index bebb26c..fafa78a 100644 --- a/test/integration-test/tls-config.test.ts +++ b/test/integration-test/tls-config.test.ts @@ -28,7 +28,7 @@ describeIntegration.each(httpsIntegrationEndpoints())( }); try { - await expect(client.refreshLiveNodes()).resolves.not.toHaveLength(0); + await expect(client.alternator.refreshNodes()).resolves.not.toHaveLength(0); await expect(client.send(new ListTablesCommand({ Limit: 1 }))).resolves.toBeDefined(); } finally { client.destroy(); @@ -43,13 +43,13 @@ describeIntegration.each(httpsIntegrationEndpoints())( const client = buildClient(endpoint, { tls: { - caFile: caCertPath, + ca: { file: caCertPath }, rejectUnauthorized: true, }, }); try { - await expect(client.refreshLiveNodes()).resolves.not.toHaveLength(0); + await expect(client.alternator.refreshNodes()).resolves.not.toHaveLength(0); await expect(client.send(new ListTablesCommand({ Limit: 1 }))).resolves.toBeDefined(); } finally { client.destroy(); @@ -65,7 +65,7 @@ describeIntegration.each(httpsIntegrationEndpoints())( const tableName = uniqueTableName("js_tls_ca_crud_it"); const client = buildClient(endpoint, { tls: { - caFile: caCertPath, + ca: { file: caCertPath }, rejectUnauthorized: true, }, }); @@ -94,12 +94,10 @@ describeIntegration.each(httpsIntegrationEndpoints())( }, compression: { request: { - enabled: true, thresholdBytes: 100, }, }, headerOptimization: { - enabled: true, allowedHeaders: [ "Host", "X-Amz-Target", @@ -114,7 +112,7 @@ describeIntegration.each(httpsIntegrationEndpoints())( const captured = captureCommandRequests(client); try { - await client.refreshLiveNodes(); + await client.alternator.refreshNodes(); await expect( client.send( new PutItemCommand({ diff --git a/test/integration-test/tls-session-resumption.test.ts b/test/integration-test/tls-session-resumption.test.ts index e19baa7..afee416 100644 --- a/test/integration-test/tls-session-resumption.test.ts +++ b/test/integration-test/tls-session-resumption.test.ts @@ -18,7 +18,6 @@ describeIntegration.each(httpsIntegrationEndpoints())( for (let index = 0; index < 10; index += 1) { await client.send(new ListTablesCommand({ Limit: 1 })); } - expect(client.alternatorConfig.tls?.sessionCache).toBeUndefined(); } finally { client.destroy(); } @@ -36,7 +35,6 @@ describeIntegration.each(httpsIntegrationEndpoints())( for (let index = 0; index < 5; index += 1) { await client.send(new ListTablesCommand({ Limit: 1 })); } - expect(client.alternatorConfig.tls?.sessionCache).toBe(false); } finally { client.destroy(); } @@ -53,9 +51,8 @@ describeIntegration.each(httpsIntegrationEndpoints())( }); try { - await expect(client.refreshLiveNodes()).resolves.not.toHaveLength(0); + await expect(client.alternator.refreshNodes()).resolves.not.toHaveLength(0); await expect(client.send(new ListTablesCommand({ Limit: 1 }))).resolves.toBeDefined(); - expect(client.alternatorConfig.tls?.sessionCache).toBe(true); } finally { client.destroy(); } diff --git a/test/middleware.test.ts b/test/middleware.test.ts index 5de6948..d391759 100644 --- a/test/middleware.test.ts +++ b/test/middleware.test.ts @@ -9,11 +9,7 @@ import type { FinalizeRequestMiddleware } from "@smithy/types"; import { Readable } from "node:stream"; import { deflateSync, gunzipSync, gzipSync } from "node:zlib"; import { describe, expect, it, vi } from "vitest"; -import { - AlternatorDynamoDBClient, - ResponseCompressionDeflate, - ResponseCompressionGzip, -} from "../src/index.js"; +import { AlternatorDynamoDBClient } from "../src/index.js"; import { alternatorUserAgentToken } from "../src/user-agent.js"; import { commandRequests, jsonResponse, RecordingHandler } from "./helpers.js"; @@ -31,7 +27,7 @@ describe("Alternator middleware", () => { discovery: { background: false }, }); - await client.refreshLiveNodes(); + await client.alternator.refreshNodes(); await client.send(new ListTablesCommand({})); await client.send(new ListTablesCommand({})); @@ -65,7 +61,7 @@ describe("Alternator middleware", () => { vi.spyOn(Math, "random").mockReturnValue(0); try { - await client.refreshLiveNodes(); + await client.alternator.refreshNodes(); await client.send(new ListTablesCommand({})); } finally { vi.restoreAllMocks(); @@ -95,7 +91,7 @@ describe("Alternator middleware", () => { maxAttempts: 3, }); - await client.refreshLiveNodes(); + await client.alternator.refreshNodes(); await client.send(new ListTablesCommand({})); expect(commandAttempts).toBe(3); @@ -189,7 +185,7 @@ describe("Alternator middleware", () => { seeds: ["seed"], requestHandler: handler, discovery: { background: false }, - compression: { request: true }, + compression: { request: {} }, }); await client.send( @@ -211,9 +207,9 @@ describe("Alternator middleware", () => { }); it.each([ - [ResponseCompressionGzip, gzipSync], - [ResponseCompressionDeflate, deflateSync], - ])("requests and decodes %s response compression", async (encoding, compress) => { + ["gzip", gzipSync], + ["deflate", deflateSync], + ] as const)("requests and decodes %s response compression", async (encoding, compress) => { const handler = new RecordingHandler((request) => { if (request.path === "/localnodes") { return ["node-a"]; @@ -232,7 +228,6 @@ describe("Alternator middleware", () => { discovery: { background: false }, compression: { response: { - enabled: true, algorithms: [encoding], }, }, @@ -252,8 +247,7 @@ describe("Alternator middleware", () => { discovery: { background: false }, compression: { response: { - enabled: true, - algorithms: [ResponseCompressionGzip], + algorithms: ["gzip"], }, }, }); @@ -274,7 +268,7 @@ describe("Alternator middleware", () => { await client.send(new ListTablesCommand({})); - expect(commandRequests(handler)[0]?.headers["accept-encoding"]).toBe(ResponseCompressionGzip); + expect(commandRequests(handler)[0]?.headers["accept-encoding"]).toBe("gzip"); }); it("adds response Accept-Encoding after SigV4 signing", async () => { @@ -289,8 +283,7 @@ describe("Alternator middleware", () => { }, compression: { response: { - enabled: true, - algorithms: [ResponseCompressionGzip], + algorithms: ["gzip"], }, }, }); @@ -298,7 +291,7 @@ describe("Alternator middleware", () => { await client.send(new ListTablesCommand({})); const headers = commandRequests(handler)[0]?.headers ?? {}; - expect(headers["accept-encoding"]).toBe(ResponseCompressionGzip); + expect(headers["accept-encoding"]).toBe("gzip"); expect(signedHeaderNames(headers.authorization)).not.toContain("accept-encoding"); }); @@ -348,13 +341,13 @@ describe("Alternator middleware", () => { seeds: ["seed"], requestHandler: replacements, discovery: { background: false }, - userAgent: "custom-client/1.2.3", + userAgent: { value: "custom-client/1.2.3" }, }); const transformedClient = new AlternatorDynamoDBClient({ seeds: ["seed"], requestHandler: transformed, discovery: { background: false }, - userAgent: (userAgent) => `${userAgent} app/4.5.6`, + userAgent: { append: "app/4.5.6" }, }); const removedClient = new AlternatorDynamoDBClient({ seeds: ["seed"], @@ -403,14 +396,14 @@ describe("Alternator middleware", () => { requestHandler: handler, discovery: { background: false }, keyRouteAffinity: { - type: "any-write", + mode: "any-write", partitionKeys: { users: "id", }, }, }); - await client.refreshLiveNodes(); + await client.alternator.refreshNodes(); await client.send( new PutItemCommand({ TableName: "users", @@ -426,7 +419,7 @@ describe("Alternator middleware", () => { const [first, second] = commandRequests(handler); expect(first?.hostname).toBe(second?.hostname); - expect(client.getPartitionKeyName("users")).toBe("id"); + expect(client.alternator.partitionKey("users")).toBe("id"); }); }); diff --git a/test/type-usage.test.ts b/test/type-usage.test.ts index 131d3d5..7a9119d 100644 --- a/test/type-usage.test.ts +++ b/test/type-usage.test.ts @@ -7,8 +7,6 @@ import { PutCommand } from "@aws-sdk/lib-dynamodb"; import { describe, expectTypeOf, it } from "vitest"; import { AlternatorDynamoDBClient, - ResponseCompressionDeflate, - ResponseCompressionGzip, routing, } from "../src/index.js"; import { AlternatorDynamoDBDocumentClient } from "../src/document.js"; @@ -18,29 +16,28 @@ describe("public type usage", () => { it("accepts native AWS SDK v3 commands", () => { const client = new AlternatorDynamoDBClient({ seeds: ["localhost"], - routing: routing.rack("dc1", "rack1", { + routing: routing.rack({ + datacenter: "dc1", + rack: "rack1", fallback: routing.cluster(), }), requestHandler: new RecordingHandler(), discovery: { background: false }, logger: console, headerOptimization: { - enabled: true, allowedHeaders: ["Host", "X-Amz-Target"], }, compression: { request: { - enabled: true, gzipLevel: -1, }, response: { - enabled: true, - algorithms: [ResponseCompressionGzip, ResponseCompressionDeflate], + algorithms: ["gzip", "deflate"], }, }, - userAgent: (userAgent) => `${userAgent} app/1.0.0`, + userAgent: { append: "app/1.0.0" }, keyRouteAffinity: { - type: "read-before-write", + mode: "read-before-write", partitionKeys: { users: "id", }, @@ -58,8 +55,8 @@ describe("public type usage", () => { ).resolves.toMatchTypeOf(); }); - it("accepts document commands without requiring DynamoDBDocumentClient.from", () => { - const docClient = new AlternatorDynamoDBDocumentClient( + it("accepts document commands from an Alternator config factory", () => { + const docClient = AlternatorDynamoDBDocumentClient.fromConfig( { seeds: ["localhost"], requestHandler: new RecordingHandler(),