MedusaJS
This wires Medusa v2 (@medusajs/framework) to samesake: pull the catalog once, keep it current with event subscribers, and expose a /store/products/search route. The official medusajs/examples Algolia integration is the closest template — the shape below mirrors it, swapping the Algolia client for samesake.
1. Map the Medusa product to a samesake collection
Section titled “1. Map the Medusa product to a samesake collection”Medusa’s Product gives you title, description, handle, status, thumbnail, tags, categories, collection, type, and variants (with sku and, via the Pricing module, calculated_price). Declare a collection that mirrors it:
import { collection, f, Channels } from "@samesake/core";
export const products = collection("products", { fields: { title: f.text({ searchable: true }), description: f.text({ searchable: true }), handle: f.text({ filterable: true }), category: f.text({ filterable: true }), collection: f.text({ filterable: true }), price: f.number({ filterable: true, budget: true }), available: f.boolean({ filterable: true }), }, embeddings: { doc: { source: "$title $description $category", model: "your-model", dim: 1536 } }, search: { channels: [ Channels.fts({ fields: ["title", "description", "category"], weight: 1 }), Channels.cosine({ embedding: "doc", weight: 1 }), ], combiner: "rrf", },});A small mapper turns a Medusa product into a samesake document. In v2 there is no inline variant.prices — read variants.calculated_price with a pricing context (region + currency):
export function toDoc(p: any) { const price = p.variants?.[0]?.calculated_price?.calculated_amount ?? null; return { id: p.id, data: { title: p.title, description: p.description ?? "", handle: p.handle, category: p.categories?.[0]?.name ?? "", collection: p.collection?.title ?? "", price, available: p.status === "published", }, };}2. Bulk index once (in-process Query graph)
Section titled “2. Bulk index once (in-process Query graph)”Prefer Medusa’s internal query.graph over HTTP for same-process sync. Page until you’ve covered metadata.count. Source: Medusa price-aware query guide.
import { QueryContext } from "@medusajs/framework/utils";
export async function bulkIndex(container, matcher) { const query = container.resolve("query"); let skip = 0; for (;;) { const { data, metadata } = await query.graph({ entity: "product", fields: ["id","title","description","handle","status","tags.value", "categories.name","collection.title","variants.id","variants.sku","variants.calculated_price.*"], filters: { status: "published" }, pagination: { take: 100, skip }, context: { variants: { calculated_price: QueryContext({ region_id: process.env.REGION_ID, currency_code: "usd" }) } }, }); if (!data.length) break; await matcher.pushDocuments("store", "products", data.map(toDoc)); skip += 100; if (skip >= (metadata?.count ?? 0)) break; } await matcher.index("store", "products");}3. Keep in sync (subscribers)
Section titled “3. Keep in sync (subscribers)”v2 subscribers live in src/subscribers/ and export a default handler plus a config. Product events carry { id }. Subscribe to create/update (re-index) and delete (remove). Source: Events & subscribers, events reference.
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework";
export default async function handler({ event: { data }, container }: SubscriberArgs<{ id: string }>) { const query = container.resolve("query"); const matcher = container.resolve("samesake"); // your module wrapping createMatcher const { data: [p] } = await query.graph({ entity: "product", fields: ["id","title","description","handle","status","categories.name","collection.title","variants.calculated_price.*"], filters: { id: data.id }, }); if (p?.status === "published") { await matcher.pushDocuments("store", "products", [toDoc(p)]); await matcher.index("store", "products"); } else { await matcher.removeDocuments("store", "products", [data.id]); }}
export const config: SubscriberConfig = { event: ["product.created", "product.updated"] };A second subscriber on product.deleted calls matcher.removeDocuments(...).
4. A search route
Section titled “4. A search route”File-based routing: src/api/store/products/search/route.ts → POST /store/products/search. Resolve your samesake module and proxy the query (the client never holds search creds). Source: API routes.
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
export async function POST(req: MedusaRequest, res: MedusaResponse) { const matcher = req.scope.resolve("samesake"); const { q, filters, limit } = req.body as any; res.json(await matcher.search("store", "products", { q, filters, limit: limit ?? 20 }));}Store routes require an x-publishable-api-key. For the Admin API during bulk sync, use a Secret API key as HTTP Basic (in v2 the key is the username, no base64).
Recommended shape
Section titled “Recommended shape”- Custom samesake module (
src/modules/samesake) wrappingcreateMatcher. - Bulk index via the Query graph on first boot / a one-off admin route.
- Subscribers for
product.created|updated|deleted→ push/index or remove. /store/products/searchproxying tomatcher.search.