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 }), }, enrich: { stages: [] }, indexing: { surfaces: { embed_doc: { kind: "dense", embedding: "doc", build: ({ data }) => `${data.title} ${data.description} ${data.category}`.trim(), }, fts_doc: { kind: "fts", build: ({ data }) => `${data.title} ${data.description} ${data.category}`.trim(), }, }, gate: ({ data }) => (data.title ? { index: true } : { index: false, reason: "missing-title" }), }, embeddings: { doc: { 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";import type { SamesakeBundle } from "@samesake/postgres";
export async function bulkIndex(container, app: SamesakeBundle) { 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 app.enrich.upsert(data.map(toDoc)); skip += 100; if (skip >= (metadata?.count ?? 0)) break; } await app.enrich.enrich();}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 app = container.resolve("samesake"); // your module wrapping samesake() 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 app.enrich.upsert([toDoc(p)]); await app.enrich.enrich(); } else { await app.enrich.remove([data.id]); }}
export const config: SubscriberConfig = { event: ["product.created", "product.updated"] };A second subscriber on product.deleted runs the same soft-delete upsert — see the TODO
above; there is no hard-delete port on the current Tier-2 bundle.
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 app = req.scope.resolve("samesake"); const { q, filters, limit } = req.body as any; res.json(await app.search(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) wrappingsamesake()from@samesake/postgres. - Bulk index via the Query graph on first boot / a one-off admin route.
- Subscribers for
product.created|updated|deleted→ upsert/enrich or soft-delete. /store/products/searchproxying toapp.search.
Run it end to end
Section titled “Run it end to end”The runnable, tested version of this guide lives in examples/medusa-sync — deterministic stubs, no API key. Run bun test (Postgres-backed examples need SAMESAKE_DATABASE_URL).