Skip to content

TypeScript: callable embedder (NodeCallableEmbedder via EmbedderChoice::InProcess) #42

Description

@coreyt

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:

  1. Sync embed happy path.
  2. Async embed (returns Promise<number[]>).
  3. Identity mismatch at configureEmbedding → typed error.
  4. Dimension mismatch at drain → FathomError surfaced from drainVectorProjection.
  5. JS exception in embed → FathomError with the exception message.
  6. auto_drain_vector=true + callable embedder — confirm the documented error (if choice (a)) OR confirm deadlock-free behavior (if choice (b)).
  7. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions