Summary
Allow TypeScript / Node callers to register a JS function as the engine's embedder. Today only EmbedderChoice::Builtin is reachable from napi — anyone wanting a non-builtin embedder (OpenAI, Cohere, local Ollama, deterministic test stub, etc.) is blocked. TypeScript parallel to the Python callable embedder (#41).
Context
The Rust engine already supports EmbedderChoice::InProcess(Arc<dyn QueryEmbedder>). The napi path in crates/fathomdb/src/node.rs::parse_embedder_choice only handles "none" and "builtin". TS builds without the default-embedder feature have no way to drive vector projection drain, which is why Pack H's TS tests degraded to wire-shape checks instead of end-to-end coverage.
Parent release: managed per-kind vector projection (branch design-db-wide-embedding-per-kind-vec, schema v24, configure_embedding / configure_vec_kind / drain_vector_projection / semanticSearch / rawVectorSearch all landed).
Scope
New napi types
NativeEmbedderHandle (#[napi]) wrapping Arc<NodeCallableEmbedder>.
NodeCallableEmbedder implementing Rust QueryEmbedder:
embed_query(&self, text) -> Result<Vec<f32>, EmbedderError> dispatches to the JS callback. Since napi/node callbacks are not directly callable from an arbitrary Rust thread, use napi::threadsafe_function::ThreadsafeFunction to marshal the call onto the Node event loop and wait for completion (blocking the calling Rust thread). A Mutex<Receiver<Result<Vec<f32>, String>>> / oneshot channel captures the result.
identity(&self) -> QueryEmbedderIdentity returns a value cached at registration time from the JS object's identity() method. The Rust side never re-queries JS for identity after registration.
max_tokens(&self) -> usize from the JS object, default 512.
BatchEmbedder impl reuses the sequential loop pattern from QueryEmbedderBatchAdapter.
Registration API
- New
#[napi] fn makeEmbedderChoiceFromCallable(embedder: JsObject) -> NativeEmbedderHandle.
- Extend
EngineCore::open (napi) with an optional embedderHandle?: NativeEmbedderHandle option alongside the existing embedder?: string. When the handle is set, the string path is ignored (or errors if both supplied).
parse_embedder_choice gains a branch: build EmbedderChoice::InProcess(handle.inner.clone() as Arc<dyn QueryEmbedder>).
TypeScript interface
interface Embedder {
identity(): {
modelIdentity: string;
modelVersion?: string;
dimension: number;
normalizationPolicy?: string;
};
embed(text: string): number[] | Float32Array | Promise<number[] | Float32Array>;
maxTokens?(): number; // optional; default 512
}
- Accept both sync and async
embed. The napi shim awaits the promise inside the threadsafe-function tick.
- camelCase on the TS side; convert to the Rust
QueryEmbedderIdentity snake_case at registration.
Identity invariant
Identity flows FROM the embedder. The napi shim reads identity() once at registration, caches it, and ignores later mutation on the JS object. AdminService::configure_embedding continues to read identity via QueryEmbedder::identity().
Thread safety
- JS callbacks can only run on the Node event loop. The drain actor runs on a Rust thread that must block waiting for the callback to return.
- Use
ThreadsafeFunction::<_, ErrorStrategy::Fatal> with blocking call mode.
- Reentrancy: if the JS callback itself calls back into fathomdb via napi, the call must not deadlock. Audit the drain path for this case; document the constraint if it cannot be relaxed.
- Write-path auto-drain (
auto_drain_vector=true on submitWrite): if the calling thread is the Node event loop, the blocking wait-for-callback creates a deadlock. Either (a) require auto_drain_vector=false when using a callable embedder, or (b) run the drain on a worker thread pool. Recommend (a) for v1 with an explicit error at engine open; document in TSDoc.
Error translation
JS errors in embed(text) → EmbedderError::Backend(format!("js embed: {msg}")). Surface the JS Error.message string.
Test plan
New typescript/packages/fathomdb/test/callable_embedder.test.ts:
class DeterministicEmbedder {
identity() {
return { modelIdentity: "test-det", modelVersion: "v1", dimension: 4, normalizationPolicy: "l2" };
}
embed(text: string) {
const h = hashString(text);
return [((h >> 0) & 0xff) / 255, ((h >> 8) & 0xff) / 255, ((h >> 16) & 0xff) / 255, ((h >> 24) & 0xff) / 255];
}
maxTokens() { return 128; }
}
test("configure + semanticSearch end-to-end with callable embedder", async () => {
const handle = makeEmbedderChoiceFromCallable(new DeterministicEmbedder());
const engine = Engine.open(tmpdir, { embedderHandle: handle });
engine.admin.configureEmbedding({ ...matching identity... });
engine.admin.configureVecKind({ kind: "chunk", source: "chunks" });
engine.submitWrite({ ...chunk with text... });
engine.admin.drainVectorProjection({ timeoutMs: 5000 });
const results = engine.semanticSearch("chunk", "hello", 5);
expect(results.length).toBeGreaterThan(0);
});
Coverage required:
- Sync
embed happy path.
- Async
embed (returns Promise<number[]>).
- Identity mismatch at
configureEmbedding → typed error.
- Dimension mismatch at drain → FathomError surfaced from
drainVectorProjection.
- JS exception in
embed → FathomError with the exception message.
auto_drain_vector=true + callable embedder — confirm the documented error (if choice (a)) OR confirm deadlock-free behavior (if choice (b)).
- Works on a napi build without
default-embedder.
Scope guardrails
- Do NOT add a new
EmbedderChoice variant; reuse InProcess.
- Do NOT support mutating the JS embedder's identity after registration.
- Do NOT attempt concurrent
embed_query from Rust without explicit serialization — the threadsafe function handles marshaling; do not call it concurrently without a queue.
- Do NOT couple this to the Python callable embedder issue — they share design intent but ship independently.
Followups / open questions
- Batched JS embed (
embedBatch(texts: string[]): number[][]) — natural extension.
- True concurrent embedding via a napi worker thread pool — out of scope.
- Whether to support Float32Array inputs alongside number[] — design choice at implementation time; low risk either way.
Memex impact
Optional — Memex's Node consumers (if any) are unblocked by the 0.5.x release using the builtin embedder. This issue unlocks alternative embedders from TS (remote APIs, test stubs, custom models).
Summary
Allow TypeScript / Node callers to register a JS function as the engine's embedder. Today only
EmbedderChoice::Builtinis reachable from napi — anyone wanting a non-builtin embedder (OpenAI, Cohere, local Ollama, deterministic test stub, etc.) is blocked. TypeScript parallel to the Python callable embedder (#41).Context
The Rust engine already supports
EmbedderChoice::InProcess(Arc<dyn QueryEmbedder>). The napi path incrates/fathomdb/src/node.rs::parse_embedder_choiceonly handles"none"and"builtin". TS builds without thedefault-embedderfeature have no way to drive vector projection drain, which is why Pack H's TS tests degraded to wire-shape checks instead of end-to-end coverage.Parent release: managed per-kind vector projection (branch
design-db-wide-embedding-per-kind-vec, schema v24,configure_embedding/configure_vec_kind/drain_vector_projection/semanticSearch/rawVectorSearchall landed).Scope
New napi types
NativeEmbedderHandle(#[napi]) wrappingArc<NodeCallableEmbedder>.NodeCallableEmbedderimplementing RustQueryEmbedder:embed_query(&self, text) -> Result<Vec<f32>, EmbedderError>dispatches to the JS callback. Since napi/node callbacks are not directly callable from an arbitrary Rust thread, usenapi::threadsafe_function::ThreadsafeFunctionto marshal the call onto the Node event loop and wait for completion (blocking the calling Rust thread). AMutex<Receiver<Result<Vec<f32>, String>>>/oneshotchannel captures the result.identity(&self) -> QueryEmbedderIdentityreturns a value cached at registration time from the JS object'sidentity()method. The Rust side never re-queries JS for identity after registration.max_tokens(&self) -> usizefrom the JS object, default 512.BatchEmbedderimpl reuses the sequential loop pattern fromQueryEmbedderBatchAdapter.Registration API
#[napi] fn makeEmbedderChoiceFromCallable(embedder: JsObject) -> NativeEmbedderHandle.EngineCore::open(napi) with an optionalembedderHandle?: NativeEmbedderHandleoption alongside the existingembedder?: string. When the handle is set, the string path is ignored (or errors if both supplied).parse_embedder_choicegains a branch: buildEmbedderChoice::InProcess(handle.inner.clone() as Arc<dyn QueryEmbedder>).TypeScript interface
embed. The napi shim awaits the promise inside the threadsafe-function tick.QueryEmbedderIdentitysnake_case at registration.Identity invariant
Identity flows FROM the embedder. The napi shim reads
identity()once at registration, caches it, and ignores later mutation on the JS object.AdminService::configure_embeddingcontinues to read identity viaQueryEmbedder::identity().Thread safety
ThreadsafeFunction::<_, ErrorStrategy::Fatal>with blocking call mode.auto_drain_vector=trueonsubmitWrite): if the calling thread is the Node event loop, the blocking wait-for-callback creates a deadlock. Either (a) requireauto_drain_vector=falsewhen using a callable embedder, or (b) run the drain on a worker thread pool. Recommend (a) for v1 with an explicit error at engine open; document in TSDoc.Error translation
JS errors in
embed(text)→EmbedderError::Backend(format!("js embed: {msg}")). Surface the JSError.messagestring.Test plan
New
typescript/packages/fathomdb/test/callable_embedder.test.ts:Coverage required:
embedhappy path.embed(returnsPromise<number[]>).configureEmbedding→ typed error.drainVectorProjection.embed→ FathomError with the exception message.auto_drain_vector=true+ callable embedder — confirm the documented error (if choice (a)) OR confirm deadlock-free behavior (if choice (b)).default-embedder.Scope guardrails
EmbedderChoicevariant; reuseInProcess.embed_queryfrom Rust without explicit serialization — the threadsafe function handles marshaling; do not call it concurrently without a queue.Followups / open questions
embedBatch(texts: string[]): number[][]) — natural extension.Memex impact
Optional — Memex's Node consumers (if any) are unblocked by the 0.5.x release using the builtin embedder. This issue unlocks alternative embedders from TS (remote APIs, test stubs, custom models).