@farukada/aws-langchain-s3-vector-ts
v1.1.0
Published
LangChain-compatible Amazon S3 Vectors store for TypeScript
Maintainers
Readme
@farukada/aws-langchain-s3-vector-ts
Built with LangChain · AWS SDK v3 · npm · GitHub · Issues
Drop-in LangChain-compatible vector store backed by Amazon S3 Vectors. Stores, queries, and manages vector embeddings using the native AWS S3 Vectors service with full TypeScript type safety. Every behaviour below is specified against the AWS API references, the @aws-sdk/client-s3vectors service model, and @langchain/core itself — and, where the service's behaviour is undocumented, against recorded live probes in docs/evidence/ that a live test re-checks.
Independent project. Maintained by Faruk Ada, one person, in their own time — see SUPPORT.md for what that means for response times. It is not affiliated with, endorsed by, or sponsored by Amazon Web Services, Inc. or LangChain, Inc. "AWS", "Amazon S3" and "Amazon S3 Vectors" are trademarks of Amazon.com, Inc. or its affiliates; "LangChain" is a trademark of LangChain, Inc. They are used here only to name the service this package talks to and the framework it plugs into.
At a glance
| | |
|---|---|
| What it is | A LangChain.js VectorStore over Amazon S3 Vectors. Drop it in wherever a store goes. |
| Maturity | S3 Vectors is generally available (2 December 2025). This package is a 1.x; see Versioning and Support for what that promises and who maintains it. |
| What it costs | Storage, writes and queries, billed by AWS — no charge for this package. Cost model has the dimensions and current figures. |
| The limits that bite | 40 KB metadata per vector, 2 KB of it filterable; 4,096 dimensions; 20 MiB per request; 2,500 vectors/s per index. Full table. |
| When it breaks | One error class, one code, structured context. Errors is the section to read first. |
| When not to use it | Sub-100 ms p99 on every query, or very high query throughput. Known limitations. |
Table of Contents
- Key Features
- Versioning and Support
- Architecture
- Quick Start
- Usage Examples
- Infrastructure Setup
- Configuration Reference
- Advanced Features
- Known limitations
- Migrating from another vector store
- API Reference
- IAM Permissions
- Testing
- Project Structure
- Design decisions and evidence
- Contributing
- License
✨ Key Features
| | Feature | Description |
|---|---|---|
| ☁️ | Cloud-Native | Direct integration with Amazon S3 Vectors via @aws-sdk/client-s3vectors |
| 🚀 | Performance-First | Per-batch embedding for low peak memory, configurable batch sizes |
| 🛡️ | Fully Type-Safe | Built with strict TypeScript 6, exported types for all config surfaces |
| 🔌 | Drop-In Compatible | Extends LangChain.js VectorStore — works with asRetriever(), RAG chains, agents |
| ⚙️ | Auto-Provisioning | Automatically creates the vector index on first write |
| 🔍 | Metadata Filtering | Native S3 Vectors metadata filters for similarity search |
| 🎯 | MMR and Enumeration | Diversity-aware search through core's own algorithm, plus listDocuments/listVectors for audit and migration |
| 🧾 | Specified, Not Guessed | Every behaviour is cited to an AWS reference, the SDK model, or a recorded live probe under docs/evidence/ that a live test re-checks |
| 🔐 | Supply-Chain Hardened | Published with npm provenance attestations via GitHub OIDC Trusted Publishing |
📌 Versioning and Support
Semantic versioning, stated as rules rather than as a link. A major is needed to remove or rename an export, narrow an input that was accepted, change a return type, or change a documented default. A minor adds an export or an optional option. A patch only fixes behaviour against what is already documented. Anything scheduled for removal carries @deprecated in its JSDoc and a CHANGELOG entry for at least one minor before the major that removes it, and keeps working until then.
Three specifics that are easy to get wrong, each stated where it applies: error codes are append-only within 1.x and a new one may arrive in a minor; DistanceMetric and VectorDataType are unions on both sides of the surface, so widening either is a major; and error messages are not covered by any of this — branch on code, context and cause, never on text.
Supported Node versions. The floor is >=22 (engines.node), and CI runs the full suite on 22, 24 and 26 across Linux, macOS and Windows. Node 22 is in maintenance until 2027-04-30; the floor will not be raised before then, and raising it afterwards is a major. New Node lines are added to the matrix when they reach Current, so a release is tested on the runtime before you are on it.
Supported peer versions. @aws-sdk/client-s3vectors and @langchain/core are peer dependencies, so you choose the versions and there is never a second copy in your tree. CI installs both at the exact floor of the declared range and runs the type checks and the unit suite against it, so a floor that was never published, or an API this package uses that the floor lacks, fails here rather than on you. Widening a range is a minor; raising a floor is a major. @langchain/core 2.x, when it arrives, will be handled in a major.
TypeScript. The published declarations are verified against TypeScript 5.x on every CI run, by installing the tarball into a fresh project and type-checking a consumer with skipLibCheck off. The build itself uses a newer compiler; that is an implementation detail and does not constrain you.
Who maintains this, and what that means. Faruk Ada, one person, in their own time — see SUPPORT.md. Response times are best effort, and there is no commercial support contract behind it. Security reports go through SECURITY.md, which commits to acknowledging within 5 business days. Only the latest release of the current major receives fixes; older versions are not patched.
Production readiness. S3 Vectors itself is generally available. This package is used in production by its author, carries 100% branch coverage with every gate run on every commit, and specifies its behaviour against AWS's own references and against recorded live probes rather than against assumption. What it does not have is a second maintainer. If that matters to your organisation, the licence is MIT and the surface is small — read the decision records and judge for yourself.
🏗️ Architecture
graph LR
A[Your Application] --> B["AmazonS3Vectors"]
B --> C["EmbeddingsInterface"]
B --> D["S3VectorsClient"]
C --> |"embedDocuments()"| B
D --> E["Amazon S3 Vectors"]
E --> F["Vector Bucket"]
F --> G["Vector Index"]
style A fill:#2d3748,stroke:#4a5568,color:#fff
style B fill:#3182ce,stroke:#2b6cb0,color:#fff
style C fill:#38a169,stroke:#2f855a,color:#fff
style D fill:#dd6b20,stroke:#c05621,color:#fff
style E fill:#805ad5,stroke:#6b46c1,color:#fff
style F fill:#805ad5,stroke:#6b46c1,color:#fff
style G fill:#805ad5,stroke:#6b46c1,color:#fffData flow on write (addDocuments):
- Documents are chunked into batches of 200 (configurable).
- Each batch is embedded via the supplied
EmbeddingsInterface. - On the first write this store instance makes (not just the first batch of this call), and only when
createIndexIfNotExistis enabled (the default), the library checks whether the index exists viaGetIndexCommandand creates it viaCreateIndexCommandif it does not, with thedimensiontaken from the first vector. Once it knows the index exists, every later write is a singlePutVectorsCommand. Nothing else is cached: the dimension is enforced by AWS on every write, and the distance metric is checked twice — against theGetIndexthat first write already makes, and against theQueryVectorsresponse on every read — so there is no stale copy of the index configuration to go wrong. APutVectorsthat reports the index gone clears the flag, so the next write re-checks and re-creates it. - Vectors plus metadata are sent via
PutVectorsCommand— one SDK call per batch, pipelined against the embedding of the next batch, at mostmaxConcurrentBatchCallsin flight. - Page content is stored as a special metadata key (
_page_contentby default) and transparently extracted on reads.
Data flow on read (similaritySearch*):
- The query is embedded via the query-side embedding model (falls back to the indexing model).
QueryVectorsCommandsends the vector withreturnMetadata: trueand optionallyreturnDistance: true.- Results are reconstructed as LangChain
Documentobjects, lifting the page content out of metadata.
📦 Quick Start
Installation
npm install @farukada/aws-langchain-s3-vector-ts @aws-sdk/client-s3vectors @langchain/corePeer Dependencies
| Package | Version |
|---|---|
| @aws-sdk/client-s3vectors | ^3.1133.0 |
| @langchain/core | ^1.2.11 |
Runtime Requirements
- Node.js
>= 22. Node 20 reached end of life on 30 April 2026 and is no longer tested; CI runs 22, 24 and 26 on Linux, macOS and Windows. Both peer dependencies still accept Node 20 and nothing in this package's runtime needs a newer API — the floor tracks maintained Node lines, not a feature. (Publishing this package to npm separately requires Node ≥24, for npm Trusted Publishing's npm-CLI requirement — that's a CI/release-only constraint and doesn't affect consumers.) - npm
>= 10.0.0. - Module format: dual. The package ships an ES module build (
dist/esm/) and a CommonJS build (dist/cjs/) compiled from the same source, and theexportsmap serves each consumer the matching one with its own declarations —importresolves to ESM andrequireto CommonJS on every supported Node version, with no dependence on Node'srequire(esm). The shape is verified on every commit by publint and arethetypeswrong, and the packed tarball is installed into a fresh project and used from ESM, CommonJS and a TypeScript 5 consumer in CI.import { AmazonS3Vectors } from '@farukada/aws-langchain-s3-vector-ts';— ESM and TypeScript.const { AmazonS3Vectors } = require('@farukada/aws-langchain-s3-vector-ts');— CommonJS.- A process that mixes both loads two copies of the module.
isS3VectorsErrorrecognises errors from either copy (it brands with a sharedSymbol.for, neverinstanceof), so an error thrown inside a CommonJS dependency is still identifiable from ESM code, and vice versa.
- Tree-shaking: the package declares
"sideEffects": false— importing one export does not pull in module-level side effects, so bundlers may drop what you don't use.
Basic Usage
import { AmazonS3Vectors } from "@farukada/aws-langchain-s3-vector-ts";
import { BedrockEmbeddings } from "@langchain/aws";
import { Document } from "@langchain/core/documents";
const store = new AmazonS3Vectors(new BedrockEmbeddings(), {
vectorBucketName: "my-vector-bucket",
indexName: "my-index",
region: "us-east-1",
});
// Add documents — embeddings are computed per batch automatically
await store.addDocuments([
new Document({ pageContent: "Star Wars", metadata: { genre: "scifi" } }),
new Document({ pageContent: "Finding Nemo", metadata: { genre: "family" } }),
]);
// Similarity search
const results = await store.similaritySearch("space adventure", 4);📖 Usage Examples
Add Documents
import { Document } from "@langchain/core/documents";
const ids = await store.addDocuments([
new Document({ pageContent: "hello world", metadata: { source: "greeting" } }),
new Document({ pageContent: "goodbye world", metadata: { source: "farewell" } }),
]);AmazonS3Vectors.fromTexts(texts, metadatas, embeddings, config) does the
text-to-Document mapping for you when you are building a store from scratch.
Similarity Search with Scores
The raw score is the distance returned by S3 Vectors — lower means more similar for both cosine and euclidean metrics.
const results = await store.similaritySearchWithScore("neural networks", 5);
for (const [doc, distance] of results) {
console.log(`${doc.pageContent} (distance: ${distance})`);
}Relevance Scores (for LangChain retrievers)
LangChain expects a relevance score (higher is better). A cosine index has one built in:
import { cosineRelevanceScoreFn } from "@farukada/aws-langchain-s3-vector-ts";
// 1.0 - distance. Cosine distance is exactly 1 - cosine similarity
// (docs/evidence/cosine-distance.md), so the range is [-1, 1] — and [0, 1]
// for the normalized embeddings most models produce.A euclidean index has none, and asking for one fails closed.
similaritySearchWithRelevanceScores on a euclidean store with no
relevanceScoreFn raises VALIDATION rather than returning a number. Euclidean
distance is unbounded above, so no fixed formula can map it to a comparable
score without knowing your embedding's scale — which only you know. Supply your
own, or use similaritySearchWithScore and threshold on the raw distance:
const store = new AmazonS3Vectors(embeddings, {
vectorBucketName: "bucket",
indexName: "index",
distanceMetric: "euclidean",
relevanceScoreFn: (d) => 1 / (1 + d),
});Use it via similaritySearchWithRelevanceScores:
const results = await store.similaritySearchWithRelevanceScores("neural networks", 5);
for (const [doc, score] of results) {
console.log(`${doc.pageContent} (relevance: ${score})`);
}Metadata Filtering
S3 Vectors supports a MongoDB-style filter syntax:
const filtered = await store.similaritySearch(
"adventure",
4,
{ genre: { $eq: "scifi" } },
);
// Range + combinator filters
const recent = await store.similaritySearch(
"announcement",
10,
{
$and: [
{ year: { $gte: 2024 } },
{ category: { $in: ["product", "security"] } },
],
},
);A few behaviours worth knowing, each recorded with its raw traffic in docs/evidence/filter-validation.md and re-checked by a live test, so a change on AWS's side fails a run rather than quietly making this section wrong:
- Don't pass an empty filter object.
similaritySearch(query, k, {})throws locally (AWS itself rejects{}with an opaque "Invalid filter" error rather than treating it as "no filter"). Omit thefilterargument entirely — or passundefined— to search without filtering. This matters if you build a filter dynamically and it can end up with no conditions applied. - One condition per object.
{ genre: "scifi", year: 2020 }is not an implicit AND: S3 Vectors rejects any condition object with more than one key, at the top level and inside$and/$oralike. Combine conditions explicitly —{ $and: [{ genre: "scifi" }, { year: 2020 }] }. Several operators on one field are fine:{ year: { $gte: 2020, $lte: 2024 } }. This library refuses the multi-key form locally, with a message showing the$andto write instead. - Operands have types.
$eq,$neand the shorthand{ field: value }take a string, a number or a boolean;$gt,$gte,$ltand$ltetake a number;$inand$nintake a non-empty array of those (mixed types are fine,nullis not);$existstakes a boolean. A field's operator object holds only these operators —{ genre: {} },{ doc: { title: "x" } }and$andinside a field are all rejected. Each rule was confirmed live, and each is enforced locally, naming the path and the rule, instead of arriving as AWS's bare "Invalid filter". NaN,InfinityandDateare refused. The AWS SDK sends a non-finite number as a string and aDateas a timestamp, so AWS would accept them and the filter would silently match nothing — or compare against a number you never wrote. Convert a date yourself to the representation the field was written with.- A type-mismatched comparison returns zero results, not an error. Comparing a boolean-valued field against a string (e.g.
{ popular: { $eq: "true" } }whenpopularis actually stored as the booleantrue) silently matches nothing rather than failing — indistinguishable from filtering on a field that doesn't exist on any document at all. This library cannot catch it for you: the stored type is the writer's and the filter value is the reader's, and no single call sees both. Watch for it wherever a filter value arrives from a query string or a form, where everything is a string. - You can't filter on a non-filterable key. Filtering on
pageContentMetadataKey(or any key listed innonFilterableMetadataKeys) fails with an "Invalid use of non-filterable metadata in filter" error — expected, since that's the whole point of the non-filterable list, but easy to hit by accident if you filter on the same key you excluded for index-size reasons.
Use as a LangChain Retriever
const retriever = store.asRetriever({ k: 5 });
const docs = await retriever.invoke("space exploration");
// With filter
const filteredRetriever = store.asRetriever({
k: 3,
filter: { genre: { $eq: "scifi" } },
});End-to-end RAG
Ingest, retrieve, answer. This compiles on every CI run against src/ and the two peer dependencies, so it is the shape a working pipeline actually has rather than an outline — the chat model and the splitter are declared rather than imported only because choosing them is yours, not this package's.
import { AmazonS3Vectors, flattenMetadata } from "@farukada/aws-langchain-s3-vector-ts";
import type { BaseLanguageModelInterface } from "@langchain/core/language_models/base";
import type { EmbeddingsInterface } from "@langchain/core/embeddings";
import { Document } from "@langchain/core/documents";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { RunnableSequence } from "@langchain/core/runnables";
// Yours: e.g. BedrockEmbeddings + ChatBedrockConverse from @langchain/aws,
// and RecursiveCharacterTextSplitter from @langchain/textsplitters.
declare const embeddings: EmbeddingsInterface;
declare const llm: BaseLanguageModelInterface;
declare const splitter: { splitDocuments(docs: Document[]): Promise<Document[]> };
declare const rawDocs: Document[];
const store = new AmazonS3Vectors(embeddings, {
vectorBucketName: "my-vector-bucket",
indexName: "knowledge-base",
distanceMetric: "cosine",
});
// ── Ingest ────────────────────────────────────────────────────────────────
// flattenMetadata is not optional here: every splitter chunk carries
// `loc: { lines: { from, to } }`, which S3 Vectors stores no version of.
const chunks = await splitter.splitDocuments(rawDocs);
await store.addDocuments(
chunks.map(
(chunk) =>
new Document({
pageContent: chunk.pageContent,
metadata: flattenMetadata(chunk.metadata),
}),
),
// Explicit ids make re-ingestion an overwrite rather than a second copy.
{ ids: chunks.map((_, index) => `doc-${index}`), batchSize: 200 },
);
// ── Retrieve and answer ───────────────────────────────────────────────────
const retriever = store.asRetriever({
k: 4,
// Higher is better — the relevance score, not AWS's raw distance.
scoreThreshold: 0.5,
});
const prompt = ChatPromptTemplate.fromMessages([
["system", "Answer using only the context. If it is not there, say so.\n\n{context}"],
["human", "{question}"],
]);
const chain = RunnableSequence.from([
{
context: async (input: { question: string }) => {
const docs = await retriever.invoke(input.question);
return docs.map((doc) => doc.pageContent).join("\n\n");
},
question: (input: { question: string }) => input.question,
},
prompt,
llm,
new StringOutputParser(),
]);
const answer = await chain.invoke({ question: "what changed in the Q3 report?" });Three things in there are specific to this store rather than to RAG in general, and each is the thing people get wrong first: flattenMetadata on every chunk, because the ecosystem's splitters emit nested metadata that the service refuses; scoreThreshold on the retriever rather than ScoreThresholdRetriever, which inverts against this store; and explicit ids, which is what makes a re-run idempotent.
Raw-Vector-Only Workflow (No Embeddings Model)
When you already have vectors (e.g., from a separate embedding service):
const store = new AmazonS3Vectors(undefined, {
vectorBucketName: "my-bucket",
indexName: "my-index",
region: "us-east-1",
});
await store.addVectors(
[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]],
[
new Document({ pageContent: "first" }),
new Document({ pageContent: "second" }),
],
);
const results = await store.similaritySearchVectorWithScore([0.1, 0.2, 0.3], 2);Separate Query Embeddings
Some embedding providers differentiate between document-side and query-side models (e.g., Cohere's input_type):
const store = new AmazonS3Vectors(documentEmbeddings, {
vectorBucketName: "my-bucket",
indexName: "my-index",
queryEmbeddings: queryEmbeddings, // falls back to documentEmbeddings if omitted
});Bring Your Own Client
import { S3VectorsClient } from "@aws-sdk/client-s3vectors";
const client = new S3VectorsClient({
region: "eu-west-1",
credentials: myCredentialProvider, // however your application resolves them
});
const store = new AmazonS3Vectors(embeddings, {
vectorBucketName: "my-bucket",
indexName: "my-index",
client, // exclusive with region/credentials/endpoint/maxAttempts/retryMode/the three timeouts
});Static Factories
// From texts
const fromTexts = await AmazonS3Vectors.fromTexts(
["hello", "world"],
[{ source: "a" }, { source: "b" }],
new BedrockEmbeddings(),
{ vectorBucketName: "my-bucket", indexName: "my-index", region: "us-east-1" },
);
// From documents
const fromDocuments = await AmazonS3Vectors.fromDocuments(
docs,
new BedrockEmbeddings(),
{ vectorBucketName: "my-bucket", indexName: "my-index", region: "us-east-1" },
);🏗️ Infrastructure Setup
Prerequisite: You must manually create the S3 vector bucket before using this library. The vector index inside the bucket is created automatically on first write (unless you disable
createIndexIfNotExist).
# Create the vector bucket
aws s3vectors create-vector-bucket \
--vector-bucket-name my-vector-bucket
# (Optional) Create the vector index manually — otherwise the library
# creates it on first write.
#
# --metadata-configuration is NOT optional if you then write through this
# library with its default `pageContentMetadataKey`. An index created without
# it treats `_page_content` as filterable, which spends the 2 KB filterable
# budget on document text; the store refuses the first write with
# INDEX_CONFIG_MISMATCH rather than writing into that. A non-filterable key
# set is fixed at creation and cannot be changed afterwards.
aws s3vectors create-index \
--vector-bucket-name my-vector-bucket \
--index-name my-index \
--data-type float32 \
--dimension 1536 \
--distance-metric cosine \
--metadata-configuration '{"nonFilterableMetadataKeys":["_page_content"]}'If you set a custom pageContentMetadataKey, use that name instead; add any
nonFilterableMetadataKeys you configure to the same list; and if you set
pageContentMetadataKey: null, omit the flag entirely. The rule is simply that
the index's list and the store's must match — letting the library create the
index is the way to not have to think about it.
- Open the Amazon S3 console.
- Select Vector buckets in the left navigation.
- Choose Create vector bucket and supply a bucket name.
- Leave the index creation to the library (automatic on first write). This is the recommended path: the library creates the index with the
dimensionfrom your first vector and withpageContentMetadataKeyin its non-filterable metadata keys. - If you create the index by hand instead, match both: the
dimensionfor your embedding model, and a non-filterable metadata key list containing_page_content(or whateverpageContentMetadataKeyyou configure). A mismatch is refused on the first write withINDEX_CONFIG_MISMATCH, and cannot be corrected afterwards — the list is fixed at creation.
There are still no L2 constructs, but aws-cdk-lib ships typed L1 constructs for both resources — aws_s3vectors.CfnVectorBucket (AWS::S3Vectors::VectorBucket) and aws_s3vectors.CfnIndex (AWS::S3Vectors::Index) — so a raw CfnResource is no longer needed. Checked against aws-cdk-lib 2.269.0.
import { aws_s3vectors as s3vectors } from "aws-cdk-lib";
const bucket = new s3vectors.CfnVectorBucket(this, "VectorBucket", {
vectorBucketName: "my-vector-bucket",
});
new s3vectors.CfnIndex(this, "VectorIndex", {
vectorBucketName: bucket.vectorBucketName,
indexName: "my-index",
dataType: "float32",
dimension: 1536,
distanceMetric: "cosine",
// Same rule as the CLI above: this must match the store's configuration,
// and it is fixed at creation.
metadataConfiguration: { nonFilterableMetadataKeys: ["_page_content"] },
});Region availability
S3 Vectors is not in every AWS Region. The authoritative list is AWS Regions, endpoints, and quotas for S3 Vectors — check it before you plan a deployment, because a Region that has S3 does not necessarily have S3 Vectors. It was in 5 Regions at preview and 34 as of 20 September 2026, so the gap is closing, but it is still a gap.
Two consequences worth knowing:
- The endpoint is
s3vectors.<region>.api.aws, a dual-stack endpoint serving IPv6 and IPv4 — not thes3.<region>.amazonaws.comshape you may expect from S3. You do not normally set it: passregionand the SDK resolves it. - Cross-Region does not exist here. A vector bucket, its indexes and the KMS key that encrypts them all live in one Region. If your embeddings model runs in another, that is a separate call and a separate Region — this store only talks to S3 Vectors.
Private connectivity (VPC endpoints)
S3 Vectors supports AWS PrivateLink interface endpoints, so traffic from your VPC reaches it without crossing the public internet. Create one for the service name com.amazonaws.<region>.s3vectors:
aws ec2 create-vpc-endpoint \
--vpc-id vpc-12345678 \
--service-name com.amazonaws.us-east-1.s3vectors \
--vpc-endpoint-type Interface \
--subnet-ids subnet-12345678 subnet-87654321 \
--security-group-ids sg-12345678 \
--ip-address-type dualstack \
--private-dns-enabledWith --private-dns-enabled, this package needs no configuration at all. Requests to the public name s3vectors.<region>.api.aws resolve to your endpoint, so an ordinary new AmazonS3Vectors(embeddings, { region, … }) already goes through it. That is the arrangement to prefer.
Without private DNS — or to pin a specific endpoint deliberately — the endpoint option is the knob:
import { AmazonS3Vectors } from "@farukada/aws-langchain-s3-vector-ts";
const store = new AmazonS3Vectors(embeddings, {
vectorBucketName: "my-vector-bucket",
indexName: "my-index",
region: "us-east-1",
// The Regional VPC endpoint DNS name. Always resolves to a private address.
endpoint: "https://vpce-1a2b3c4d-5e6f.s3vectors.us-east-1.vpce.amazonaws.com",
});A VPC endpoint policy restricts what can be reached through it; s3vectors:* actions and the ARNs in IAM Permissions are what you would name in one.
Encryption with a customer managed key
By default S3 Vectors encrypts everything with S3-managed keys (SSE-S3, AES-256) at no extra cost. If your organisation requires a customer managed KMS key, there are three things to get right, and two of them are not guessable from the error you get when they are wrong.
1. Set it at index creation, because it cannot be set afterwards. An index inherits its bucket's encryption unless it is given its own, and neither can be changed once created. This store forwards encryptionConfiguration verbatim to CreateIndex — so it applies only when the store creates the index (createIndexIfNotExist: true, the default) and is ignored for one that already exists.
import { AmazonS3Vectors } from "@farukada/aws-langchain-s3-vector-ts";
const store = new AmazonS3Vectors(embeddings, {
vectorBucketName: "my-vector-bucket",
indexName: "my-index",
encryptionConfiguration: {
sseType: "aws:kms",
// A full ARN. S3 Vectors does not accept a key ID or an alias, and this
// package refuses the pairing rules AWS enforces before the request goes out.
kmsKeyArn: "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012",
},
});2. The key policy must name the S3 Vectors service principal. This is the step that is easy to miss: the service encrypts and decrypts on your behalf, so indexing.s3vectors.amazonaws.com needs kms:Decrypt on the key. Without it, writes fail with KMS_ERROR and nothing in the message says which principal is missing.
3. Your own principals need kms:Decrypt and kms:GenerateDataKey — the first to read, the second to write. Scope them with kms:ViaService so the grant only applies through S3 Vectors:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowS3VectorsServicePrincipal",
"Effect": "Allow",
"Principal": { "Service": "indexing.s3vectors.amazonaws.com" },
"Action": "kms:Decrypt",
"Resource": "*",
"Condition": {
"ArnLike": { "aws:SourceArn": "arn:aws:s3vectors:<region>:<account-id>:bucket/*" },
"StringEquals": { "aws:SourceAccount": "<account-id>" },
"ForAnyValue:StringEquals": {
"kms:EncryptionContextKeys": ["aws:s3vectors:arn", "aws:s3vectors:resource-id"]
}
}
},
{
"Sid": "AllowApplicationAccess",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::<account-id>:role/VectorApplicationRole" },
"Action": ["kms:Decrypt", "kms:GenerateDataKey"],
"Resource": "*",
"Condition": {
"StringEquals": { "kms:ViaService": "s3vectors.<region>.amazonaws.com" }
}
}
]
}Enforcing it across an account. S3 Vectors publishes two IAM condition keys — s3vectors:sseType (AES256 or aws:kms) and s3vectors:kmsKeyArn — so a service control policy can require SSE-KMS, or require one specific key. The user guide introduces them as condition keys for vector buckets, but the Service Authorization Reference lists both on CreateIndex as well as CreateVectorBucket — so a policy can deny an index created without the required encryption, including one this store creates. A store configured without encryptionConfiguration sends none, so under such a policy its CreateIndex is refused with ACCESS_DENIED: set encryptionConfiguration to match, or pre-create the index. Creating the bucket with the right encryption, and letting indexes inherit it, remains the simplest arrangement.
See Data protection and encryption in S3 Vectors for AWS's full treatment, and docs/evidence/index-encryption.md for the pairing rules this package enforces locally and how they were established.
⚙️ Configuration Reference
| Option | Type | Default | Description |
|---|---|---|---|
| vectorBucketName | string | required | Name of an existing S3 vector bucket. 3–63 characters, lowercase letters, digits and hyphens only, beginning and ending alphanumeric — no dots, unlike an index name. AWS enforces these at bucket creation, so a name breaking them names no bucket that exists; this package refuses it at construction rather than on the first request. |
| indexName | string | required | Name of the vector index (3–63 chars; lowercase letters, numbers, -, .) |
| client | S3VectorsClient | — | Pre-configured SDK client. Mutually exclusive with every option that would configure one: supplying client together with region, credentials, endpoint, maxAttempts, retryMode, connectionTimeout, socketTimeout or requestTimeout raises VALIDATION rather than silently ignoring them |
| region | string | — | AWS region, with no surrounding whitespace (not allowed together with client) |
| credentials | AwsCredentialIdentity | — | AWS credentials (not allowed together with client) |
| endpoint | string | — | Custom endpoint URL, which must have a host: localhost:4566 without a scheme is refused, since it parses with no host and every request would go to a host named after the port (not allowed together with client) |
| dataType | "float32" | "float32" | Vector data type (S3 Vectors currently only supports float32) |
| distanceMetric | "cosine" \| "euclidean" | "cosine" | Distance metric for similarity search |
| createIndexIfNotExist | boolean | true | Auto-create the index on first write. false issues no GetIndex at all, so a store that never creates an index needs no control-plane permission — see IAM Permissions. The check that rides on that GetIndex goes with it: an existing index's distance metric and non-filterable keys are then not compared with this configuration on a write. The metric is still verified on every read; the non-filterable keys are not, so configure both to match the index you provisioned |
| encryptionConfiguration | EncryptionConfiguration (SDK type) | bucket default | Server-side encryption for an index this store creates, e.g. { sseType: "aws:kms", kmsKeyArn: "arn:aws:kms:…" }. Ignored for an existing index (encryption is fixed at creation; S3 Vectors has no UpdateIndex) |
| tags | Record<string, string> | — | Tags applied to an index this store creates (cost allocation, ABAC). Ignored for an existing index. When this store creates the index, tags CreateIndex refuses — more than 50, a character outside letters, numbers, spaces and _ . : / = + - @, or a key under the reserved aws: prefix, each confirmed live (docs/evidence/tag-rules.md) — are refused with VALIDATION before the request |
| maxConcurrentBatchCalls | number | 10 | Cap on concurrent PutVectors/DeleteVectors/GetVectors calls within one call — two concurrent addVectors have two of these windows, not one. It bounds memory, not the store's request rate; writeRateLimit is what bounds the rate. Peak in-flight write payload scales with maxConcurrentBatchCalls × batchSize — see Rate Limits, Payload Limits and Cost |
| writeRateLimit | { vectorsPerSecond?: number; requestsPerSecond?: number } | false | { vectorsPerSecond: 2500, requestsPerSecond: 1000 } | How fast this store may write, in the two units AWS counts per index, shared by every call on the store. Defaults to AWS's own documented limits; false turns pacing off. This is the knob that keeps concurrent writers inside the limit — maxConcurrentBatchCalls bounds one call's requests in flight, not the store's rate — see Rate Limits, Payload Limits and Cost |
| pageContentMetadataKey | string \| null | "_page_content" | Metadata key for storing Document.pageContent; null to disable round-tripping |
| nonFilterableMetadataKeys | string[] | — | Metadata keys excluded from query filters (reduces index size for large values). pageContentMetadataKey is added to this list too (unless it is null); the merged result must fit an index — at most 10 keys, each 1–63 characters, a key listed twice counting once — and a list that could not is refused with VALIDATION at construction, before any AWS call, rather than the key being silently dropped. This list must also match the index being written to: it sets the local 2 KB filterable-metadata budget, and a disagreement with an existing index raises INDEX_CONFIG_MISMATCH. See Non-Filterable Metadata Keys. |
| queryEmbeddings | EmbeddingsInterface | — | Separate embedding model for queries only |
| relevanceScoreFn | (distance: number) => number | — | Custom distance-to-score conversion. Its result is used as given: a NaN score compares false against a retriever's scoreThreshold, so that document is dropped |
| embeddings | EmbeddingsInterface | — | Alternative to the positional embeddings argument |
| maxAttempts | number | SDK default | Max attempts (initial + retries) for AWS requests (not allowed together with client) |
| retryMode | "standard" \| "adaptive" | SDK default | AWS SDK retry mode (not allowed together with client) |
| connectionTimeout | number (ms) | 5000 | Ceiling on the connection phase of a request; 0 disables it (not allowed together with client) |
| socketTimeout | number (ms) | 60000 | Ceiling on how long a socket may sit idle before the request fails — the one that ends a request to an endpoint that accepts the connection and then never answers. Being idle-based, it does not cut short a large upload that is still making progress. 0 disables it (not allowed together with client) |
| requestTimeout | number (ms) | — | A total deadline for a request and its response. Deliberately not defaulted: a 500-vector batch at 4,096 dimensions is a large upload, and a deadline would end it however healthy the transfer is. Setting it also sets the SDK's throwOnRequestTimeout, without which the SDK only warns and keeps waiting. 0 disables it (not allowed together with client) At most 2,147,483,647 ms, the longest delay Node's timers hold: a longer one is cut to 1 ms and every request would time out at once, so it is refused |
Every method that takes an options bag — addVectors, addDocuments, getByIds, delete, deleteIndex, listDocuments, listVectors, maxMarginalRelevanceSearch — refuses a non-object one with VALIDATION rather than reading each option in it as unset. undefined and null still mean "no options". The two enumeration methods raise it on the first next(), where an out-of-range pageSize is also raised, so one try around the loop catches both.
Every option above is validated at construction, before any AWS call. A closed-set option (distanceMetric, dataType, encryptionConfiguration.sseType) is checked against the SDK's own enum, so the check cannot drift from the service model; the rest are shape and bound checks (pageContentMetadataKey 1–63 characters or null and never __proto__, nonFilterableMetadataKeys an array of strings that, merged with pageContentMetadataKey, must fit an index — at most 10 keys, each 1–63 characters, relevanceScoreFn a function, tags string keys of 1–128 and values of at most 256 characters, maxConcurrentBatchCalls a positive integer, writeRateLimit false or positive finite rates, region a non-empty string, endpoint an absolute URL, credentials a credential pair or a provider function, maxAttempts an integer of 1 or more, createIndexIfNotExist a boolean — the string "false" an environment variable hands you is refused, not read as truthy — and each of the three timeouts a non-negative integer). Each raises VALIDATION naming the option, and the credentials message describes the value by kind without ever echoing it. The alternative is a round trip that fails, or — for relevanceScoreFn — an uncoded TypeError thrown from inside a search hours later.
Constructing a store issues no AWS request.
Full generated API docs: see docs/ (TypeDoc output).
A misspelled option is refused, not ignored. An unknown key that differs from a real option only in case, or — from six characters up — is within two edits of one or is the start of one, raises VALIDATION naming both. createIndexIfNotExists (one letter out) used to construct a store that created the index with every default, and an index's configuration cannot be changed afterwards. Unknown keys that are not near misses are still accepted, deliberately: @langchain/core's SemanticSimilarityExampleSelector passes its own k, filter, exampleKeys and inputKeys through the same object, and the closest any of those comes to an option here is four edits. So spreading an application config object into the constructor is fine, unless it happens to carry a near miss.
Only the options listed above are read by this library. The constructor builds its S3VectorsClient from exactly region, credentials, endpoint, maxAttempts, retryMode and a requestHandler carrying the three timeouts — any other S3VectorsClientConfig field (a logger, a customUserAgent, a proxy, a fully custom retryStrategy, a requestHandler of your own, …) is not passed through. Build the client yourself and hand it in via client for anything beyond those; every operation then flows through your client unchanged, its own timeouts included, and this library imposes none of its own on it.
Retries
Throttling (TooManyRequestsException, HTTP 429 — S3 Vectors' name for it; not ThrottlingException, which several other AWS services use and this one never sends) and transient 5xx failures are retried automatically by the AWS SDK's retry strategy — 3 attempts total (1 + 2 retries) with exponential backoff and jitter under the default "standard" mode. This library adds no retry layer of its own: a THROTTLED or SERVICE_UNAVAILABLE error you catch means the SDK's attempts were exhausted.
One SERVICE_UNAVAILABLE will never recover: a mistyped region or endpoint fails DNS resolution (ENOTFOUND), which the SDK retries as transient and this library therefore reports as SERVICE_UNAVAILABLE with retryable: true. region is checked only as a non-empty string, so "us-est-1" constructs a store whose every request fails this way — check the configuration before backing off.
Tune it with maxAttempts / retryMode, or pass a fully pre-configured client:
retryMode: "adaptive"adds a client-side token bucket that slows the request rate after throttling rather than only retrying. It is not a substitute forwriteRateLimit, and this package no longer recommends it for bulk ingest: measured against the load that fails without pacing — eight concurrentaddVectorson one store — adaptive mode still took 95TooManyRequestsExceptions and still failed every call, at 774 vectors/s, because it reacts only once throttling has begun. In a lighter four-writer run it cost 9× throughput for the same number of 429s (docs/evidence/write-rate.md). Pace the writes instead; keepadaptivefor sharing an account-level quota with workloads this store knows nothing about.maxAttemptscontrols attempts per individual call (e.g. onePutVectorsbatch), not peraddDocuments. Raising it lengthens the worst-case time a single batch can block.- Every error from an AWS call carries
context.retryable(truefor throttling, 5xx, a timed-out or reset connection, and a refused or unreachable one),context.awsErrorName,context.httpStatusCodeandcontext.requestId, so an application-level retry or dead-letter decision can be made from the error alone — see Errors.
The embeddings side is different: embedDocuments/embedQuery come from your embeddings model, and this library never retries them (the interface gives no way to know whether a failure is safe to retry). Configure retries on the embeddings client itself. A model that throws surfaces as EMBEDDINGS_FAILED — a code of its own, so a retry policy for a provider outage does not also retry a bug — with the provider's error as error.cause and no context.awsCommand; when the model is itself built on an AWS SDK (Bedrock embeddings, say) and throws that SDK's own service exception or TimeoutError, context.awsErrorName and context.retryable still describe it — about the model's service, not S3 Vectors.
Errors
Every failure — validation, not-found, or an underlying AWS error — is surfaced as a single typed S3VectorsError carrying a code (S3VectorsErrorCode), a context ({ operation, awsCommand, vectorBucketName, indexName, … }), and the original cause. Detect it with the exported isS3VectorsError() guard — it's a proper TypeScript type guard, so a caught unknown narrows to S3VectorsError without a cast:
try {
await store.addDocuments(docs);
} catch (e) {
if (isS3VectorsError(e)) {
console.error(e.code, e.message); // e is narrowed, no `as S3VectorsError` needed
}
}context.operation names the method you called; context.awsCommand names the request that failed. operation is always the public method you called — addDocuments, getByIds, deleteIndex, fromDocuments, retriever.invoke — whatever raised the error underneath, and never an AWS command, even when an AWS request is what failed. That request is awsCommand: GetIndex, CreateIndex, DeleteIndex, GetVectorBucket, PutVectors, DeleteVectors, QueryVectors, GetVectors or ListVectors, set on every error that wraps a failed AWS request (an ABORTED that cancelled one in flight included) and absent from every other — a validation error, an abort that cancelled no request, a failure of your embeddings model or relevanceScoreFn, and an AWS_INVALID_RESPONSE about a response that did arrive. So an addDocuments whose PutVectors is refused reports operation: "addDocuments" with awsCommand: "PutVectors", a retriever whose search is throttled reports operation: "retriever.invoke" with awsCommand: "QueryVectors", and concurrent writes that share one failed index check each report their own method.
When several things are wrong at once, one order decides which error you get, on every public method: (1) every refusal the arguments alone decide (VALIDATION, and addVectors' INDEX_CONFIG_MISMATCH for vectors that disagree on dimension) — the options bag, argument types and counts, ids, documents and metadata, batchSize/pageSize/k/other options, signal type, filter; (2) ABORTED for an already-fired signal; (3) an empty input ([] ids, [] documents) returns its empty result, without a request or an embedding call; (4) only then is anything spent — resolving the embeddings model (EMBEDDINGS_MISSING), embedding, and the AWS request itself. So a malformed id beats a fired signal, a fired signal beats an empty input's free return, and addDocuments([]) on a store with no embeddings model resolves [] rather than raising EMBEDDINGS_MISSING. A retriever's own fields are arguments to asRetriever(), so they are checked there, before any invoke; retriever.invoke then follows the same order for its query and its config.
Partial-batch failures report what already succeeded. Nothing is written for an input this library can refuse: every document, id and vector it can check is checked before the first request, and — for addDocuments — before the first embedding call. A multi-batch write can still fail partway (a later batch throttled, a transient error, an embeddings model returning an unusable vector for a later batch), and then earlier batches are already committed in AWS — the thrown error's context.writtenIds lists every id confirmed written before the failure, including any concurrent batch that happened to succeed alongside the one that failed. It is a lower bound, not the exact set: a request that was aborted or timed out after it reached AWS may have been stored without being confirmed, so an id in context.attemptedIds but not in context.writtenIds is in an unknown state. Retrying with attemptedIds is always safe, because a write to an existing key overwrites it. This matters most with auto-generated ids: without context.writtenIds, those vectors would be undiscoverable and impossible to clean up or reconcile, since nothing else records what id they landed under. delete({ ids }) reports the equivalent context.deletedIds on a partial failure — lower-stakes since delete is idempotent (a blind retry of the full ids list is always safe), but still useful to know exactly what happened. deleteIndex() is idempotent in the same way: deleting an index that is already gone resolves cleanly instead of erroring, so retrying after an ambiguous network failure is safe.
This partial-progress guarantee doesn't extend to search: if a multi-page QueryVectors pagination sequence fails partway through, any pages already fetched are discarded rather than returned alongside the error. Reasonable asymmetry — a failed search is side-effect-free and trivially retryable, unlike a failed write — but worth knowing if you're relying on writtenIds/deletedIds-style partial-progress reporting from a read path too. The error does still report how far it got, via context.pagesScanned and context.resultsCollected, and a failure on page 2 or later says so explicitly — AWS pagination tokens are only valid for a few minutes, so the fix for a long-running paginated search is to re-issue the original query rather than resume it.
try {
await store.addDocuments(manyDocuments); // ids auto-generated
} catch (e) {
if (isS3VectorsError(e) && e.context.writtenIds?.length) {
console.warn(`${e.context.writtenIds.length} vectors already written before the failure:`, e.context.writtenIds);
}
}The codes are stable and exhaustive. Everything a caller branches on is its own
class, decided from the exception's name, which is a literal type on every
exception the service declares — never a substring match on a message:
| Code | Raised when |
| --- | --- |
| VALIDATION | Caller input was invalid — a mismatched count, a missing or non-array argument, an options bag that is not an object, the removed deleteAll option, a document, text or metadatas entry of the wrong type, a bad batch size, page size, k, fetchK or lambda, a retriever searchType other than similarity or mmr, a retriever.invoke timeout that is not a whole number of milliseconds from 1 to 2,147,483,647, a signal that is not an AbortSignal or is passed in the callbacks slot, a malformed filter (one condition per object, operand types per operator, no NaN/Date), a reserved metadata key or metadata S3 Vectors cannot store (including an empty or mixed array), a malformed or repeated vector id, a vector S3 Vectors cannot store or search with (a component beyond the float32 range included), a string that is not well-formed UTF-16 anywhere it would be sent to AWS, a text query that is not a string, a configuration option outside its documented set (an endpoint with no host, a region with surrounding whitespace, a requestTimeout past 2,147,483,647 ms), tags CreateIndex would refuse when this store creates the index, a client supplied alongside the options that would configure one, or a relevance-score search on a euclidean index with no relevanceScoreFn. Also an embeddings model returning something other than one storable vector per document, or an unusable query vector. Raised before any AWS call and before any billable embedding, with two exceptions. A model's output is refused after that embedding call and before the request it would feed; on a write it carries context.writtenIds, because earlier batches may already be written. Tags CreateIndex would refuse are checked only when this store creates the index, so they are refused after the first batch is embedded and before CreateIndex is sent. An error about one element of a list carries context.recordIndex (its position in your input) and, where it has one, context.recordId. |
| AWS_REJECTED | ValidationException (400): AWS itself refused the request. context.fieldList carries the field-level detail AWS returned, which is the actionable half of an otherwise opaque rejection. |
| THROTTLED | TooManyRequestsException (429). Retry after a backoff; the SDK has already retried. |
| SERVICE_UNAVAILABLE | InternalServerException (500), ServiceUnavailableException (503) or RequestTimeoutException (408), or the SDK's own TimeoutError — a connection, socket-idle or request timeout, or a connection reset or broken on the way. Also a refused, unreachable or DNS-failed connection, matched on the error's own Node.js system error code against the codes the SDK's retry strategy lists as transient (the SDK also finds such a code in the error's cause; this package does not). Transient — except that a 503 from PutVectors is also AWS's documented answer to a batch exceeding resource capacity, which backoff cannot fix. The two are indistinguishable by code, so a write failure carries context.batchSize: that is what tells you whether to back off or to split. |
| ACCESS_DENIED | AccessDeniedException (403), or any other AWS refusal answered with HTTP 403 — which is how rejected credentials arrive (InvalidClientTokenId, MissingAuthenticationToken); context.awsErrorName keeps the name. An IAM or credentials problem, not a retryable one. A missing s3vectors:GetVectors raises it on any search as well as on enumeration, because both request metadata; the message says so in each case. |
| QUOTA_EXCEEDED | ServiceQuotaExceededException (402). Needs a quota increase, not a retry. |
| CONFLICT | ConflictException (409) from CreateIndex: the index already exists. Two writers racing to create the same index is normal and handled internally — the loser re-reads the index and checks the winner's non-filterable metadata keys against its own, so a disagreement surfaces as INDEX_CONFIG_MISMATCH rather than as this code. CONFLICT surfaces only when it is not that race. |
| KMS_ERROR | One of the four KMS exceptions (400). Key state — an operator's problem, not a caller's. |
| NOT_FOUND | NotFoundException (404): the bucket or index is not there. deleteIndex raises it only for a bucket that does not exist — an index that is already gone is a success there, and the two are told apart by asking GetVectorBucket (see IAM Permissions). Not a missing vector id — getByIds reports that as undefined in the id's slot (see getByIds and missing ids). |
| EMBEDDINGS_MISSING | An operation needed an embedding model but none was configured. The message names which option to set. |
| AWS_REQUEST_FAILED | An AWS request failed and no narrower class applies. context.awsCommand says which request, and context.awsErrorName/httpStatusCode/requestId/retryable say what AWS answered and whether to retry. |
| INDEX_CONFIG_MISMATCH | An existing index disagrees with this store's configuration: its distance metric, checked both against the GetIndex that precedes a first write — so a write-only workload catches it too — and against the QueryVectors response on every read, so it cannot go stale; or its non-filterable metadata keys, checked against that same GetIndex. That GetIndex is only issued with createIndexIfNotExist: true (the default): with false, the read-side metric check is the only one left, and a write-only workload catches neither mismatch. Also raised when the vectors a write is given disagree on dimension — anywhere in an addVectors call, before any request; within one embedded batch for addDocuments, before that batch is written. |
| ABORTED | The supplied AbortSignal fired before or during the operation. error.cause is the signal's reason, always normalised to an Error. |
| AWS_INVALID_RESPONSE | An AWS response was missing, or carried an unusable value for, something this library requires — a non-numeric distance, an unrecognised distanceMetric, a vector returned without data despite returnData: true, a result with no string key or with metadata that is not an object, metadata structuredClone cannot copy, or a response that was not an object at all. Reachable only from a mocked, stubbed or otherwise non-conforming client. |
| PAGE_LIMIT_EXCEEDED | A paginated read stopped with pages still outstanding. context.awsCommand says which: on a search, the 1,000-page runaway ceiling was reached with fewer than k results collected, and context.pagesScanned/context.resultsCollected say how far short it fell — narrow the filter or lower k. On either, the service handed back a pagination token it had already handed out — a page already read, so following it would repeat results or never end; on an enumeration context.pagesScanned/context.yielded say how far it got. Any repeat is caught, not only the same token twice in a row. That is not the index being large — it is a replayed or cached response, typically from a custom endpoint, a proxy or a stubbed client. A read that legitimately runs out returns what it found, without error; removing that ambiguity is what this code is for. |
| EMBEDDINGS_FAILED | Your embeddings model threw — embedDocuments on a write, embedQuery on a text search. The provider's own error is error.cause. It never touched AWS, so there is no context.awsCommand; a model that is itself built on an AWS SDK (Bedrock embeddings, say) still carries context.awsErrorName and context.retryable, about its service. On a write it carries context.writtenIds, because earlier batches may already be written. A model that returns something unusable, rather than throwing, is VALIDATION. |
| UNEXPECTED_ERROR | A failure that never touched AWS and was not the embeddings model — a raw throw from other caller-supplied code (a relevanceScoreFn, a callback handler), or input malformed enough to bypass validation. The commonest of those is a getter that throws: reading a property off a document, its metadata, a filter, an options bag or the configuration runs whatever getter is behind it — a lazily loaded ORM field, a revoked Proxy, a config object that raises on a missing variable — and that is your code running inside the check. Every public method, and both constructors, report it this way, naming the method you called and keeping what the getter threw as error.cause; it is refused before anything is spent. |
The codes are append-only for 1.x. A value is never removed, never renamed, and never reassigned to a different condition, so a code you stored or logged means the same thing for all of 1.x; S3VectorsErrorContext only gains fields, and operation is always present. A new code may arrive in a minor — the set grows, it does not change underneath you. That is safe for a switch with a default and for an if on a single code, and it is the reason to write them that way: Record<S3VectorsErrorCode, T> and a never-typed exhaustiveness assertion are the two shapes a new member breaks, and they are explicitly not covered by this promise. Error messages are not covered either — branch on code, on context and on cause, never on text. isS3VectorsError is the supported way to recognise these errors: it checks a brand, Symbol.for('@farukada/aws-langchain-s3-vector-ts:S3VectorsError'), rather than instanceof, so it works across realms and across the ESM and CommonJS copies of the module, and that brand string is stable for 1.x too.
Logging errors safely. error.context.instance (set only by the fromDocuments/fromTexts factories) is a live store handle for programmatic recovery. It is a non-enumerable property, so JSON.stringify(error.context), util.inspect(error), console.error(error) and structured loggers all omit it; direct access still works. Independently of that, the store keeps every internal — the SDK client included — in a #private field, so it is neither an enumerable own property nor reachable from outside the class at all, and credentials are additionally excluded from LangChain's lc_kwargs. Printing a store, or an error that carries one, therefore cannot leak credential material. Regression tests pin both, and a third pins that no internal name appears in util.inspect(store) at depth.
What an error does carry, and why it matters for PII. Credentials, document text, query text and embedding vectors never appear in a message or in context — a rejected value is described by kind, and a metadata value only ever by NaN/Infinity. Ids are different, and deliber
