aetherfy-vectors
v1.2.2
Published
JavaScript/TypeScript SDK for Aetherfy Vectors - Global vector database with automatic replication and low latency worldwide
Downloads
435
Maintainers
Readme
Aetherfy Vectors JavaScript SDK
Memory for AI agents — conversations, knowledge bases, scrape logs, trading histories. Named scopes you create and search, replicated across regions, with no infrastructure to run.
🌟 Features
- 🧠 Memory Primitives -
Namespacefor any named scope,Threadfor conversations — not a raw collection API - 🌍 Global Performance - Automatic replication across all three regions:
us-east-1,eu-central-1,ap-southeast-1 - ⚡ Intelligent Caching - High cache hit rate, with responses served from cache where possible
- 🛡️ Zero DevOps - Fully managed infrastructure, no setup required
- 🤝 Workspaces - every collection lives in one; inside a deployed agent the agent's own is used
- 📊 Built-in Analytics - Real-time performance metrics and insights
- 🔧 Auto-Failover - Seamless failover and disaster recovery
- 🔒 Enterprise Security - End-to-end encryption and compliance ready
- 🚀 Developer Friendly - TypeScript-first with comprehensive documentation
- 🪜 Escape Hatch -
AetherfyVectorsClientunderneath for raw collections and points
🚀 Installation
Requires Node.js >= 20. Ships CommonJS, ESM, and UMD browser builds with first-class TypeScript types.
npm install aetherfy-vectors
# or
yarn add aetherfy-vectors
# or
pnpm add aetherfy-vectors📖 Quick Start
Every memory lives in a scope you create by name. Two kinds: a namespace for anything with a name, a thread for a conversation. Bring your own embedding vectors — Aetherfy stores and searches them, it does not generate them.
import { MemoryClient } from 'aetherfy-vectors';
const memory = new MemoryClient(); // inside an Aetherfy agent: its key and its workspace
// --- A namespace: any named scope ---------------------------------------
await memory.createNamespace('customer-42');
const customer = await memory.namespace('customer-42');
await customer.add({
text: 'Lives in NYC, prefers email',
vector: await embed('Lives in NYC, prefers email'),
});
const hits = await customer.search(
await embed('where is this customer based?'),
{ limit: 5 }
);
// --- A thread: a conversation -------------------------------------------
await memory.createThread('conv-99');
const thread = await memory.thread('conv-99');
await thread.add({ role: 'user', content: 'hi', vector: await embed('hi') });
await thread.add({
role: 'assistant',
content: 'hello',
vector: await embed('hello'),
});
const recent = await thread.history({ limit: 20 }); // in message order
// Clearing a scope is atomic. After it, the scope no longer exists.
await thread.clear();Scopes must be created before you write to them, so a typo throws instead of silently creating a second store.
A namespace is one collection, and takes its own vectorSize (default 384;
pass 1536 for OpenAI small, 3072 for large, 1024 for Cohere v3).
Threads do not work that way. Every thread in a workspace lives in one
shared collection with the thread id as a payload key, so a conversation costs
no collection slot and a plan's collection limit does not cap how many
conversations you can have. One collection means one dimension for all of them,
so createThread takes no options: they come from the client.
That collection is __threads__, one in each workspace (collection names are
unique per workspace). It is managed by the SDK, and Aetherfy refuses to move
or rename it: it holds the conversations of every agent in its workspace, so
moving it would hand them to another workspace's agents. An agent that moves
to another workspace leaves its conversation history behind in the old one.
const memory = new MemoryClient({ threadVectorSize: 1536 }); // OpenAI smallDeployed on Aetherfy, new MemoryClient() takes no arguments at all: the control
plane injects AETHERFY_API_KEY and AETHERFY_WORKSPACE_ID (the agent's
workspace) at deploy time. Anywhere else, name a workspace:
new MemoryClient({ workspace: 'default' }).
Full memory API — iteration, bulk loading, metadata — under Memory SDK below.
🧱 The Low-Level Client
AetherfyVectorsClient is the layer MemoryClient is built on, exported and
supported. Use it when you want raw collections and points rather than named
scopes, or an operation the memory layer doesn't expose.
The snippets below build on each other; each assumes the imports and client from the previous ones.
import { AetherfyVectorsClient, DistanceMetric } from 'aetherfy-vectors';
// Initialize client
const client = new AetherfyVectorsClient({
apiKey: 'afy_live_your_api_key_here', // or use environment variable
workspace: 'default', // every collection lives in a workspace; every account starts with "default"
});
// Create a collection
await client.createCollection('products', {
size: 384, // Vector dimensions
distance: DistanceMetric.COSINE
});
// Add vectors — point ids are unsigned integers (≤ 2^53 − 1) or UUID strings
const points = [
{
id: 1,
vector: [0.1, 0.2, ...], // 384-dimensional vector
payload: {
name: 'Wireless Headphones',
category: 'electronics',
price: 299.99
}
}
];
await client.upsert('products', points);
// Search for similar items
const results = await client.search('products', queryVector, {
limit: 10,
withPayload: true,
scoreThreshold: 0.7
});
console.log('Similar products:', results);🔑 Authentication
The SDK supports multiple ways to provide your API key:
1. Constructor Parameter (Recommended)
const client = new AetherfyVectorsClient({
apiKey: 'afy_live_your_api_key_here',
workspace: 'default',
});2. Environment Variables (Node.js only)
# Option 1
export AETHERFY_API_KEY=afy_live_your_api_key_here
# Option 2
export AETHERFY_VECTORS_API_KEY=afy_live_your_api_key_here// No API key needed in constructor
const client = new AetherfyVectorsClient({ workspace: 'default' });3. Custom Endpoint
const client = new AetherfyVectorsClient({
apiKey: 'your_api_key',
workspace: 'default',
endpoint: 'https://custom.vectors.endpoint.com',
timeout: 45000, // Custom timeout in milliseconds
});4. Local development across regions
apiRegion pins which regional API endpoint the client connects to —
a transport/routing override, NOT where collections live (for collection
placement see createCollection(..., regions)). Production agents have
AETHERFY_VECTORS_URL injected by the control-plane — that's the URL they
reach the regional backend through, and it takes precedence over
apiRegion. For local development (no env var injected), you can pin a
client to a specific API region — but apiRegion requires the async
factory rather than new, because the SDK has to call
GET /api/v1/regions to resolve the per-region URL:
const client = await AetherfyVectorsClient.create({
apiKey: 'afy_test_...',
workspace: 'default',
apiRegion: 'eu-central-1', // 'us-east-1' | 'eu-central-1' | 'ap-southeast-1'
});create() mirrors Python's AetherfyVectorsClient(api_key=..., api_region='eu-central-1')
contract: when the call resolves, the client is fully ready — endpoint,
analytics, and .apiRegion are all final. Discovery errors surface at the
create() call site instead of being deferred to your first method call.
If you call new AetherfyVectorsClient({apiRegion: 'eu-central-1'}) without an
override (endpoint= or AETHERFY_VECTORS_URL), the constructor
throws telling you to use create() — async discovery isn't safe
inside a sync constructor and silent deferral is a footgun.
If both AETHERFY_VECTORS_URL and apiRegion are set, the env var wins
and a warning is logged — production-agent protection rule.
Unknown options throw
Every options object the SDK takes — the client constructor and create(),
search, retrieve, scroll, scrollIter, count, setPayload, the
MemoryClient constructor, the Namespace / Thread methods,
retryWithBackoff, HttpClient and fanOut — accepts exactly the keys its TypeScript type declares. Any
other key throws a TypeError naming the key, the method and the accepted
keys, even in plain JavaScript and even when its value is undefined:
new AetherfyVectorsClient({ apiKey: 'afy_test_...', region: 'eu-central-1' });
// TypeError: AetherfyVectorsClient constructor: unknown option(s): region.
// Accepted: apiKey, endpoint, timeout, enableConnectionPooling, workspace, apiRegion.The API endpoint option is apiRegion. The Python SDK raises TypeError
for an unknown argument the same way. new MemoryClient({ client }) uses that
client as-is, so passing a connection key beside it (apiKey, endpoint, …)
throws too, naming the key.
🔁 Iterating Large Collections
For bulk reads, use scrollIter() rather than scroll({ limit: … }).
The async iterator pages transparently and stays within the server's
per-request caps (1000 points/call, 10 MB/response):
import type { ScrollIterOptions, ScrollPoint } from 'aetherfy-vectors';
for await (const point of client.scrollIter('my_collection', {
batchSize: 256,
})) {
process(point);
}
// With a filter and selective payload/vector return
const opts: ScrollIterOptions = {
batchSize: 512,
scrollFilter: { must: [{ key: 'status', match: { value: 'active' } }] },
withPayload: true,
withVectors: false,
};
for await (const point of client.scrollIter('my_collection', opts)) {
process(point);
}batchSize is the page size for one HTTP round trip (max 1000
server-side). The iterator handles cursor management, page exhaustion,
and pagination errors — no offset bookkeeping in user code.
An unknown option throws. Passing
limitinstead ofbatchSizethrows aTypeErrorat thescrollIter(...)call, naming the key and listing the accepted ones —{ batchSize: 256, limit: 100 }would otherwise page at 256 withlimitdropped. See Unknown options throw.
✏️ Editing Payload on Existing Points
Three operations on the payload of points that already exist — no need to re-upsert vectors:
// MERGE: add or update keys, leave others alone
await client.setPayload(
'my_collection',
{ reviewed: true, reviewer: 'alice' },
[pointId]
);
// OVERWRITE: replace the entire payload object
await client.overwritePayload('my_collection', { category: 'X' }, [pointId]);
// DELETE: remove specific keys, leave others alone
await client.deletePayload(
'my_collection',
['draft_field', 'stale_score'],
[pointId]
);Each call accepts up to 512 points in one round trip; for larger
mutations, batch on the caller side. Semantics map exactly to qdrant's
set_payload / overwrite_payload / delete_payload.
🧠 Memory SDK — Iter, Bulk-load, setMetadata
The Memory layer (MemoryClient) layers Namespace and Thread
abstractions on top of AetherfyVectorsClient. Three additions worth
knowing once you go past add() / search():
Iterating a namespace or a thread
import { MemoryClient } from 'aetherfy-vectors';
import type { NamespaceIterOptions } from 'aetherfy-vectors';
const memory = new MemoryClient({ apiKey: 'afy_live_…', workspace: 'my-bot' });
const ns = await memory.namespace('customer-42');
for await (const point of ns.iter({ batchSize: 256 })) {
process(point);
}
// Threads have iterHistory() — yields messages in ts order across the
// whole conversation. Distinct from history({ limit: N }), which caps
// at 5000 in memory for the most-recent slice.
const thread = await memory.thread('conv-99');
for await (const msg of thread.iterHistory({ order: 'asc' })) {
console.log(msg.role, msg.content);
}Use history({ limit: N }) for "show me the last N messages" (bounded,
fast). Use iterHistory() for "walk every message in this thread"
(paged, memory-bounded by the iterator).
Bulk-loading memories
addMany() and appendMany() batch into a single client.upsert so
N items become 1 round trip. IDs are returned in input order; missing
IDs are auto-generated as canonical UUIDs (the same format iter() and
retrieve() yield back, so equality comparisons just work).
const items = [
{ text: 'first', vector: embed('first'), metadata: { src: 'a' } },
{ text: 'second', vector: embed('second'), metadata: { src: 'b' } },
];
const ids = await ns.addMany(items); // single round trip; preserves input order
// Threads use appendMany — role/content/ts payloads, ts auto-set per
// message when omitted (each message gets its own ts, not one shared).
const msgs = [
{ role: 'user', content: 'hi', vector: embed('hi') },
{ role: 'assistant', content: 'hello', vector: embed('hello') },
];
const msgIds = await thread.appendMany(msgs);Threads have no addMany() — a Thread is not a Namespace subclass
(they share a scope base but declare their own write API), so there is
no addMany to call. addMany writes text/metadata payloads,
which don't fit a thread's role/content/ts schema. Use
appendMany() on threads.
setMetadata — atomic replace, explicit-compose merge
setMetadata() replaces the entire metadata sub-key.
setMetadata({ tag: 'x' }) nukes every other key. Use mergeMetadata
if you want additive updates that preserve existing keys. Reserved
fields (text for Namespace; role/content/ts for Thread) are
untouched either way.
await ns.setMetadata(pointId, { reviewed: true, score: 0.92 });To merge into existing metadata via the explicit-compose pattern (race visible at the call site, no atomicity guarantee):
const [point] = await ns.retrieve([pointId]);
const current = (point?.payload?.metadata ?? {}) as Record<string, unknown>;
await ns.setMetadata(pointId, { ...current, reviewed: true });If two callers run this concurrently, one update wins and the other sees its read be stale — by design, you see that race in your own code rather than have the SDK hide it.
mergeMetadata — atomic per-point partial merge
mergeMetadata({ tag: 'x' }) adds/updates the listed keys and leaves
every other key untouched. Concurrent patches to different keys all
land atomically; concurrent writes to the same key resolve via
last-writer-wins per the storage operation order. Throws
PointNotFoundError if the point doesn't exist. Reserved keys (text
on Namespace; role, content, ts on Thread) cannot appear in the
partial — throws a local TypeError before the request is sent.
await ns.mergeMetadata(pointId, { reviewed: true });
await ns.mergeMetadata(pointId, { score: 0.92 });
// final metadata: original keys + reviewed + scoredeleteMetadataKeys — atomic key removal
deleteMetadataKeys(pointId, ['tag', 'score']) removes the listed keys
from metadata; keys not in the list are left untouched. Throws
PointNotFoundError if the point doesn't exist. Reserved keys cannot
appear in the keys list (same set as mergeMetadata).
await ns.deleteMetadataKeys(pointId, ['draft', 'staleScore']);📐 Limits
Two axes constrain a single call: per-request size (PRS) and requests
per minute (RPM). Both axes return a 4xx with a structured error.code
when they fire — no surprise 5xx, no silent truncation.
| Class | Endpoint | Cap |
| ------ | -------------------------------- | ------------------------------------- |
| READS | scroll · search · retrieve | ≤ 1000 points/call · ≤ 10 MB/response |
| WRITES | upsert | ≤ 10 K vectors/call · streaming |
| | payload edits · batch delete | ≤ 512 points/call |
Upserts stream — there is no body-size cap on the public upsert URL. The 10 K vectors/call is a defensive request-level ceiling, not a body limit; one call can upload millions of bytes via byte-target chunking on the receiving end. For bulk reads, use
scrollIter()— it pages transparently and stays within both quotas.
requests_per_minute is a sliding-window minutely cap derived from
your subscription tier. When it fires, the SDK throws
RateLimitExceededError with a structured retryAfter (seconds);
PRS violations throw ValidationError (400) or surface as 413
RESPONSE_TOO_LARGE for oversized response bodies.
🤝 Multi-Agent Workspaces
Every collection lives in exactly one workspace, and every account starts with
one named default. A workspace decides where a collection is reachable from:
agents in the same workspace see each other's collections, and an agent reaches
another workspace only if the account owner granted it access.
Collection names are unique per workspace: invoices in customer-a and
invoices in customer-b are two collections.
Choosing the workspace
const client = new AetherfyVectorsClient({
apiKey: 'afy_live_your_api_key_here',
workspace: 'invoice-pipeline', // All operations are scoped to this workspace
});workspace defaults to 'auto': inside an agent deployed on Aetherfy, the
agent's own workspace, by id, from AETHERFY_WORKSPACE_ID (the control plane
injects it on every agent). A workspace rename never breaks an agent, because
it is addressed by id.
// Inside a deployed agent: its own workspace, nothing to configure.
const client = new AetherfyVectorsClient();There is no "no workspace". workspace: null, '', and 'auto' outside an
agent (where AETHERFY_WORKSPACE_ID is not set, including every browser) throw
at construction, saying to pass a workspace name or run inside an agent.
client.workspace is the name (undefined when built from the id) and
client.workspaceId the id (undefined when given a name).
'auto' is only the agent's home workspace. Code that works across several
workspaces (one per customer, say) passes each workspace's name explicitly,
one client per workspace.
How scoping works
You always use the collection's own name; the client sends it on the workspace's routes:
// Create a collection in the client's workspace
await client.createCollection('documents', {
size: 768,
distance: DistanceMetric.COSINE,
});
// Search — no need to know the full scoped name
const results = await client.search('documents', queryVector, { limit: 10 });
// List — only returns collections in your workspace
const collections = await client.getCollections();
// → [{ name: 'documents', ... }] (short names, not scoped names)Multi-agent example
// Agent A: extractor
const extractor = new AetherfyVectorsClient({
apiKey: process.env.AETHERFY_API_KEY,
workspace: 'invoice-pipeline',
});
await extractor.createCollection('raw-invoices', {
size: 768,
distance: DistanceMetric.COSINE,
});
await extractor.upsert('raw-invoices', extractedPoints);
// Agent B: classifier — same workspace, sees Agent A's collection
const classifier = new AetherfyVectorsClient({
apiKey: process.env.AETHERFY_API_KEY,
workspace: 'invoice-pipeline',
});
const results = await classifier.search('raw-invoices', queryVector, {
limit: 20,
});Workspace errors
import { WorkspaceNotFoundError } from 'aetherfy-vectors';
try {
await client.getCollections();
} catch (error) {
if (error instanceof WorkspaceNotFoundError) {
// No such workspace in this account. A typo is never read as "empty".
console.log(`Unknown workspace: ${error.workspace ?? error.workspaceId}`);
}
}collectionExists() is false only when the workspace exists and the
collection is not in it; an unknown workspace throws.
When a request is retried
One rule, the same in the Python and JS SDKs (src/retry-policy.ts). Up to 3
retries, waiting about 1 s, 2 s, then 4 s (each jittered to 50-100%), within
a call's timeout.
| Failure | Retried for |
|---|---|
| Refused before anything ran: 503 WORKSPACE_LOOKUP_UNAVAILABLE, COLLECTION_LOOKUP_UNAVAILABLE, PLACEMENT_UNAVAILABLE; a connection never made (DNS, refused, connect timeout); 429 with a Retry-After | every operation |
| Ambiguous, the request may have run: a read timeout, a reset after sending, any other 5xx | only operations whose repeat gives the same result (below) |
| Any other 4xx | never |
| Operations | Class |
|---|---|
| getCollections, getCollection, collectionExists, getSchema, analyzeSchema, search, retrieve, scroll, count, getUsageStats | reads: repeatable |
| upsert, setPayload, overwritePayload, deletePayload, createFieldIndex, setSchema | writes keyed by id: repeatable |
| createCollection, deleteCollection, delete (points), deleteSchema, deleteFieldIndex | once: a repeat after a hidden success reports a false error, so an ambiguous failure is raised after one attempt |
setSchema is repeatable because it is sent without If-Match: a repeat writes
the same schema again. A conditional write would get a false 412 on a repeat
after a hidden success, so it would be classed once.
A connection "never made" is known from the SDK's own HTTP agents, which watch whether each socket ever connected (Node and Bun both give them). Where no such information exists (a custom agent, an adapter or runtime that gives no socket) a transport failure counts as "may have been sent": only repeatable operations are retried.
When the retries run out, the last error is raised (ServiceUnavailableError, RequestTimeoutError or NetworkError).
The client already retries under this rule. To retry more around your own
operation, say whether repeating it gives the same result: REPEATABLE (a
read, a write keyed by id) or ONCE (a create, a delete). The same failures
are retried as above; a 4xx never is.
import { retryWithBackoff, REPEATABLE } from 'aetherfy-vectors';
const results = await retryWithBackoff(
() => client.search('collection', vector),
{
retryClass: REPEATABLE,
maxRetries: 3,
baseDelay: 1000,
}
);Renaming a collection, and moving one to another workspace, are done from the
dashboard or the CLI (afy collections update), not from the SDK. Code still
using the old name gets CollectionNotFoundError.
Tip: Create workspaces in the dashboard or with the CLI (
afy workspaces create invoice-pipeline). Agents deployed to a workspace receive its id asAETHERFY_WORKSPACE_ID.
🤖 Running as an Agent
Code deployed to Aetherfy as an agent gets a set of helpers from the same
package, on the aetherfy-vectors/agent subpath. Nothing to add to your
package.json: the standard runtime image preinstalls aetherfy-vectors, and
a version you pin yourself wins over it. A custom container installs it like
any other package.
Each of these is a thin wrapper over a platform contract the docs already publish; the helper exists so the contract stops being copied into every task.
import { payload, machine, fanOut, spawn } from 'aetherfy-vectors/agent';
// This run's input. A scheduled fire sends none, so {} is the normal case.
const data = await payload();
// The machine this run is on: numbers, not the strings the env carries.
const shape = machine();
// An in-machine pool. Results come back in INPUT order and no failure is
// swallowed: the lowest-indexed rejection is re-thrown once every worker has
// settled. Width defaults to vcpus x 8, which suits I/O-bound work.
const answers = await fanOut(summarise, (data.items as string[]) ?? []);
// Narrower, when each item is heavy.
const digests = await fanOut(hashOne, files, { width: shape.vcpus });
// Run a different task agent, on its own machine, with its own lifecycle.
const run = await spawn('nightly-rollup', { date: '2026-09-07' });
console.log(run.spawn_id, run.region, run.status);fanOut prints one line to the run's logs before it starts, so how wide a run
went is visible after the fact:
aetherfy: fanning out 32 wide on 4 vCPU / 8192 MB (120 tasks)fanOut is promise concurrency, which is all I/O-bound work needs — model
calls and HTTP requests fan out on the event loop and never touch a thread.
CPU-bound work belongs in worker_threads, sized from shape.vcpus.
Spawning has three outcomes worth telling apart. The payload cap and the concurrent-run cap get their own types; everything else carries the platform's stable error code, which is the thing to branch on:
import {
spawn,
PayloadTooLarge,
SpawnError,
TooManyRunsInFlight,
} from 'aetherfy-vectors/agent';
try {
await spawn('nightly-rollup', { date: '2026-09-07' });
} catch (error) {
if (error instanceof PayloadTooLarge) {
// The payload is for parameters and references, not data. Write the data
// to a collection and pass its id.
console.error(error.payloadBytes, 'exceeds', error.maxBytes);
} else if (error instanceof TooManyRunsInFlight) {
// The one refusal here worth retrying: runs are finishing all the time.
// The cap is the ACCOUNT's and is set by your plan; `limit` names which
// plan limit was hit, and `maxInFlightRuns` is null on an uncapped plan.
console.error(
'waiting on',
error.inFlightCount,
'of',
error.maxInFlightRuns
);
} else if (error instanceof SpawnError) {
console.error(error.code);
} else {
throw error;
}
}Returning a result, and reading one back
Output on Aetherfy mirrors input. A task writes its answer with writeResult;
whoever started the run reads it back off the run itself, so a parent hears from
a child in another region with no side channel between them.
import { writeResult } from 'aetherfy-vectors/agent';
// In the child task. The mirror of payload(): a file on the machine, nothing
// over the network. Returning nothing is the normal case, so most tasks never
// call this at all.
await writeResult({ rows: 128, date: '2026-09-08' });import { spawn, wait, result } from 'aetherfy-vectors/agent';
// In the parent. wait() holds one request open instead of polling; a run that
// has not finished in time comes back exactly as it stands, which is not an
// error — read `state` and call again.
const run = await spawn('nightly-rollup', { date: '2026-09-08' });
const finished = await wait(run.spawn_id, 45);
if (finished.state === 'completed') {
console.log(finished.result);
}
// result() is the same read without the waiting.
const now = await result(run.spawn_id);
console.log(now.state, now.has_result, now.result_error);The result shares the payload's inline cap — one number bounding both directions — and it is for answers and references, not data. Anything larger belongs in a collection, with its id in the result:
import { writeResult, ResultTooLarge } from 'aetherfy-vectors/agent';
try {
await writeResult(everything);
} catch (error) {
if (error instanceof ResultTooLarge) {
console.error(error.resultBytes, 'exceeds', error.maxBytes);
await writeResult({
collection: 'nightly-rollup',
rows: everything.length,
});
} else {
throw error;
}
}Reading a run has the same shape as spawning it: the two refusals worth telling apart get their own types, and everything else carries the platform's stable error code.
import {
result,
RunAccessDenied,
RunNotFound,
RunReadError,
} from 'aetherfy-vectors/agent';
try {
await result(runId);
} catch (error) {
if (error instanceof RunNotFound) {
console.error('no run has that id');
} else if (error instanceof RunAccessDenied) {
console.error('that run belongs to another account');
} else if (error instanceof RunReadError) {
console.error(error.code);
} else {
throw error;
}
}With the agent's own AETHERFY_API_KEY, a read the control plane does not
allow that key (which ones it allows)
is refused with RunReadError and code AUTH_AGENT_KEY_OUT_OF_SCOPE.
Full contract, including the environment variables behind every call: docs.aetherfy.com/agents/task-contract.
🧩 Payload Schemas
Collections can carry an optional payload schema that the SDK validates against before upsert — catching malformed payloads client-side without a round trip. Schemas are cached and automatically revalidated when they change server-side (via ETag).
import { Schema, EnforcementMode } from 'aetherfy-vectors';
const schema: Schema = {
fields: {
title: { type: 'string', required: true },
price: { type: 'float', required: true },
tags: { type: 'array', required: false, elementType: 'string' },
inStock: { type: 'boolean', required: false },
},
description: 'Product catalog payloads',
};
// enforcement: 'off' (no validation), 'warn' (log warnings), 'strict' (throw on violation)
const etag = await client.setSchema('products', schema, 'strict');
const current = await client.getSchema('products'); // null if no schema is defined
await client.deleteSchema('products');Infer a schema from existing data
const analysis = await client.analyzeSchema('products', 1000); // sampleSize: 100–10000
await client.setSchema('products', analysis.suggestedSchema, 'warn');Schema violations throw SchemaValidationError with a detailed per-field errors array. If the server reports a stale schema (412 Precondition Failed), the SDK auto-refreshes the cache and retries. Use refreshSchema(name) or clearSchemaCache(name?) for manual control.
🔧 Core Operations
Collection Management
// Create collection with vector configuration.
// Returns the created Collection (not a boolean). `.regions` reflects the
// resolved placement: the full scope when `regions` is omitted, or the
// explicit subset you passed.
const collection = await client.createCollection('my-collection', {
size: 768,
distance: DistanceMetric.EUCLIDEAN,
});
console.log(collection.name, collection.regions);
// Pin a collection to a subset of your scope (placement, NOT the
// connection apiRegion). An empty array is rejected by the server.
await client.createCollection(
'eu-only',
{ size: 768, distance: DistanceMetric.COSINE },
'EU-resident collection',
['eu-central-1']
);
// List all collections
const collections = await client.getCollections();
// Check if collection exists
const exists = await client.collectionExists('my-collection');
// Payload indexes. A filter on an UNINDEXED key is scanned, not looked up —
// index any key you filter on for every read (a tenant id, a status, a
// timestamp you range over). fieldSchema: 'keyword' | 'integer' | 'float' |
// 'bool' | 'geo' | 'datetime' | 'uuid' | 'text', or a parameterised object.
// Resolves once the index is built, so a filter or an orderBy scroll on the
// key can follow straight away. A large collection can take longer than the
// server's 25 s wait; the call then waits on, up to { timeout } (ms, default
// 600000). Past it, RequestTimeoutError: the build carries on, and calling
// again waits.
await client.createFieldIndex('my-collection', 'tenantId', 'keyword');
await client.createFieldIndex('my-collection', 'ts', 'integer', {
timeout: 120000,
});
// true when the collection exists, even if the field was never indexed;
// false only when the collection does not exist.
await client.deleteFieldIndex('my-collection', 'tenantId');
// Get collection info
const info = await client.getCollection('my-collection');
// Delete collection
await client.deleteCollection('my-collection');Vector Operations
// Insert or update vectors. A point id is an unsigned integer (≤ 2^53 − 1)
// or a UUID string — anything else is rejected client-side with the same
// error the server would return (400 INVALID_POINT_ID).
await client.upsert('collection-name', [
{
id: '550e8400-e29b-41d4-a716-446655440001',
vector: [0.1, 0.2, 0.3, ...],
payload: { category: 'A', metadata: {...} }
},
{
id: '550e8400-e29b-41d4-a716-446655440002',
vector: [0.4, 0.5, 0.6, ...],
payload: { category: 'B', metadata: {...} }
}
]);
// Retrieve vectors by ID
const vectors = await client.retrieve(
'collection-name',
[
'550e8400-e29b-41d4-a716-446655440001',
'550e8400-e29b-41d4-a716-446655440002',
],
{
withPayload: true,
withVectors: false
}
);
// Delete vectors
await client.delete('collection-name', [
'550e8400-e29b-41d4-a716-446655440001',
'550e8400-e29b-41d4-a716-446655440002',
]);
// Delete by filter
await client.delete('collection-name', {
must: [{ key: 'category', match: { value: 'A' } }]
});
// Count vectors
const count = await client.count('collection-name');
const filteredCount = await client.count('collection-name', {
countFilter: { must: [{ key: 'category', match: { value: 'A' } }] }
});Search Operations
// Basic similarity search
const results = await client.search('collection-name', queryVector, {
limit: 10,
withPayload: true,
withVectors: false,
});
// Advanced search with filtering. Three clauses, and only three:
// must, mustNot, should.
const filteredResults = await client.search('collection-name', queryVector, {
limit: 20,
offset: 10,
scoreThreshold: 0.8,
withPayload: true,
queryFilter: {
must: [
{ key: 'category', match: { value: 'electronics' } },
{ key: 'price', range: { gte: 100, lte: 500 } },
],
mustNot: [{ key: 'status', match: { value: 'discontinued' } }],
},
});
// Trade latency for recall: a larger hnsw_ef makes the graph walk visit more
// candidates. Omit searchParams to keep the tuned server default (hnsw_ef=100).
const preciseResults = await client.search('collection-name', queryVector, {
searchParams: { hnsw_ef: 256 },
});searchParams is passed through untranslated as the request body's params —
the API and Qdrant own the schema, so the SDK validates nothing and needs no
release to track new params. Note that the server cache key is derived from the
request body, so the same query at a different hnsw_ef is a separate cache
entry (never a wrong hit).
Filter clauses
This SDK speaks camelCase: the clause is mustNot, and the SDK translates it
to the engine's must_not on the wire. A clause name outside
must / mustNot / should throws before the request is sent — including
must_not, which is the Python SDK's spelling and is rejected with a message
naming the camelCase form. Nothing unknown is forwarded, and nothing is
dropped: an unrecognized clause used to vanish silently, turning a narrowing
filter into a match-everything query. The rule holds everywhere a filter is
accepted — search, scroll, scrollIter, count, delete.
import type { Filter } from 'aetherfy-vectors';
const filter: Filter = {
must: [{ key: 'category', match: { value: 'electronics' } }],
mustNot: [{ key: 'status', match: { value: 'discontinued' } }],
};
const inStock = await client.count('collection-name', { countFilter: filter });
await client.delete('collection-name', filter);📊 Usage & Limits
Aetherfy does not report latency or cache-hit telemetry through this SDK. Time your own calls — that figure includes your network path to us, and it is the only latency your users actually experience.
// Monitor usage and limits
const usage = await client.getUsageStats();
usage.storage_bytes_used; // number — bytes stored across every collection
usage.storage_limit_bytes; // number|null — null on an unlimited tier
usage.collections_count; // number — active collections
usage.collections_limit; // number|null — null on an unlimited tier
usage.tier; // string — the plan's tier name
usage.active_regions; // string[] — union of your collections' regions
usage.usage_percentage; // number — storage %, 0 when there is no limit
console.log(
`Collections: ${usage.collections_count}/${usage.collections_limit}`
);
console.log(`Storage: ${usage.storage_bytes_used} bytes`);
console.log(`Tier: ${usage.tier}`);
// Usage warning example
if (usage.storage_limit_bytes !== null && usage.usage_percentage > 80) {
console.warn('⚠️ Approaching storage limit');
}These are the endpoint's own field names, in its own snake_case, returned untouched — the SDK's camelCase vocabulary is for the options you send, not for the bodies you get back.
🌐 Browser Usage
The SDK works in browsers with important security considerations:
<!DOCTYPE html>
<html>
<head>
<title>Vector Search Demo</title>
</head>
<body>
<script type="module">
import { AetherfyVectorsClient } from 'https://unpkg.com/[email protected]/dist/browser.js';
// ⚠️ Only use test keys in browser - never production keys!
const client = new AetherfyVectorsClient({
apiKey: 'afy_test_demo_key_only', // Test key only!
workspace: 'default', // a browser has no agent workspace: name one
});
// The SDK will show security warnings in browser console
async function searchProducts(query) {
try {
const results = await client.search('products', query, {
limit: 5,
withPayload: true,
});
displayResults(results);
} catch (error) {
handleError(error);
}
}
</script>
</body>
</html>🔒 Browser Security Guidelines
- Never expose production API keys in browser code
- Use test keys only for demos and development
- Configure CORS on your server for browser requests
- Use backend proxy for production applications
- Consider serverless functions for secure API access
🔧 Advanced Usage
Error Handling
import {
AetherfyVectorsClient,
AetherfyVectorsError, // base class — catch this for a generic fallback
AuthenticationError,
RateLimitExceededError,
ValidationError,
NotFoundError, // parent of every not-found error
CollectionNotFoundError,
PointNotFoundError,
WorkspaceNotFoundError,
ServiceUnavailableError,
RequestTimeoutError,
NetworkError,
ConflictError,
CollectionInUseError,
QuotaExceededError,
SchemaNotFoundError,
SchemaValidationError,
} from 'aetherfy-vectors';
try {
await client.search('collection', vector);
} catch (error) {
if (error instanceof AuthenticationError) {
console.error('Invalid API key');
} else if (error instanceof RateLimitExceededError) {
console.error(`Rate limited. Retry after: ${error.retryAfter}s`);
} else if (error instanceof ValidationError) {
console.error('Invalid request:', error.message);
} else if (error instanceof CollectionNotFoundError) {
console.error(`Collection ${error.collectionName} not found`);
} else if (error instanceof SchemaValidationError) {
// `errors` is one entry per rejected point: { index, id, errors: [{ field, code, message }] }
console.error('Schema violations:', error.errors);
} else if (error instanceof QuotaExceededError) {
console.error(
`Quota '${error.quotaType}' exceeded: ${error.current}/${error.limit}`
);
} else if (error instanceof ServiceUnavailableError) {
// Already retried under the rule ("When a request is retried"). Calling
// again is safe only for a repeatable operation: an ambiguous failure of
// a delete or a create may have succeeded.
}
}Batch Operations
// Process large datasets in batches
import { batchArray } from 'aetherfy-vectors';
const largeDataset = [...]; // Your large array of points
const batches = batchArray(largeDataset, 100); // Process 100 at a time
for (const batch of batches) {
await client.upsert('collection', batch);
console.log(`Processed batch of ${batch.length} points`);
}Environment Detection
import { isBrowser, isNode } from 'aetherfy-vectors';
if (isBrowser()) {
console.log('Running in browser - use test keys only');
} else if (isNode()) {
console.log('Running in Node.js - can use environment variables');
}Resource Cleanup
Long-lived processes (e.g. CLIs, tests) should close the underlying HTTP pool so Node can exit cleanly:
client.destroy();📚 API Reference
AetherfyVectorsClient
| Method | Description | Returns |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------- |
| createCollection(name, config, description?, regions?) | Create a new collection (returns the created Collection, incl. resolved regions) | Promise<Collection> |
| deleteCollection(name) | Delete a collection | Promise<boolean> |
| getCollections() | List all collections | Promise<Collection[]> |
| collectionExists(name) | Check if collection exists | Promise<boolean> |
| getCollection(name) | Get collection info | Promise<Collection> |
| createFieldIndex(collection, field, schema?, options?) | Create a payload index on one field (default 'keyword'); resolves once built | Promise<boolean> |
| deleteFieldIndex(collection, field) | Drop a payload index; false only when the collection does not exist | Promise<boolean> |
| upsert(collection, points) | Insert/update vectors | Promise<boolean> |
| delete(collection, selector) | Delete vectors | Promise<boolean> |
| retrieve(collection, ids, options) | Get vectors by ID | Promise<Point[]> |
| search(collection, vector, options) | Similarity search | Promise<SearchResult[]> |
| count(collection, options) | Count vectors | Promise<number> |
| getSchema(collection) | Get payload schema | Promise<Schema \| null> |
| setSchema(collection, schema, mode?, desc?) | Define/update schema | Promise<string> (ETag) |
| deleteSchema(collection) | Remove schema | Promise<boolean> |
| analyzeSchema(collection, sampleSize?) | Infer schema from data | Promise<AnalysisResult> |
| refreshSchema(collection) | Force schema cache refresh | Promise<void> |
| clearSchemaCache(collection?) | Clear schema cache | void |
| getUsageStats() | Account usage | Promise<UsageStats> |
| testConnection() | Test API connection | Promise<boolean> |
| destroy() | Close HTTP connections | void |
Distance Metrics
import { DistanceMetric } from 'aetherfy-vectors';
DistanceMetric.COSINE; // Cosine similarity (recommended for most use cases)
DistanceMetric.EUCLIDEAN; // Euclidean distance
DistanceMetric.DOT; // Dot product
DistanceMetric.MANHATTAN; // Manhattan distanceTypes
interface VectorConfig {
size: number; // Vector dimensions
distance: DistanceMetric;
}
interface Point {
id: string | number; // Unsigned integer (≤ 2^53 − 1) or UUID string
vector: number[]; // Vector data
payload?: Record<string, any>; // Optional metadata
}
interface SearchOptions {
limit?: number; // Max results (default: 10)
offset?: number; // Skip results (default: 0)
withPayload?: boolean; // Include metadata (default: true)
withVectors?: boolean; // Include vectors (default: false)
scoreThreshold?: number; // Min similarity score
queryFilter?: Filter; // Filter conditions
searchParams?: Record<string, unknown>; // Engine params, sent verbatim as `params`
}
interface SearchResult {
id: string | number; // Unsigned integer (≤ 2^53 − 1) or UUID string
score: number; // Similarity score
payload?: Record<string, any>; // Metadata (if requested)
vector?: number[]; // Vector (if requested)
}🧪 Testing
# Run all tests
npm test
# Run tests with coverage
npm run test:coverage
# Run browser compatibility tests
npm run test:browser
# Run specific test file
npm test -- --testNamePattern="client"
# Watch mode during development
npm run test:watch🏗️ Building
# Install dependencies
npm install
# Build for production
npm run build
# Build and watch for changes
npm run build:watch
# Type checking
npm run type-check
# Linting
npm run lint
# Format code
npm run format📊 Performance
Characteristics
- Latency: served from the region nearest the caller
- Caching: built in, on by default
- Failover: automatic between regions
- Regions:
us-east-1,eu-central-1,ap-southeast-1
Best Practices
- Use appropriate batch sizes (100-1000 points per upsert)
- Enable caching for repeated queries
- Use filters to reduce search space
- Monitor usage to stay within limits
- Choose optimal distance metric for your use case
🤝 Contributing
We welcome contributions! Open an issue or a pull request on GitHub.
Development Setup
# Clone the repository
git clone https://github.com/l-td/aetherfy-vectors-js-sdk.git
cd aetherfy-vectors-js-sdk
# Install dependencies
npm install
# Run development build
npm run dev
# Run tests
npm test📄 License
This project is licensed under the MIT License - see the LICENSE file for details.
🆘 Support
- Documentation: https://docs.aetherfy.com/vectors
- API Reference: https://docs.aetherfy.com/vectors/api
- SDK Reference: https://docs.aetherfy.com/vectors/sdk
- GitHub Issues: Report bugs and request features
- Email: [email protected]
🔗 Related Projects
- Python SDK: aetherfy-vectors (Python)
Made with ❤️ by the Aetherfy team
