Skip to content

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:

search/catalog.ts
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):

search/to-doc.ts
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.

src/workflows/index-products.ts (sketch)
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();
}

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.

src/subscribers/samesake-sync.ts
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.

File-based routing: src/api/store/products/search/route.tsPOST /store/products/search. Resolve your samesake module and proxy the query (the client never holds search creds). Source: API routes.

src/api/store/products/search/route.ts
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).

  1. Custom samesake module (src/modules/samesake) wrapping samesake() from @samesake/postgres.
  2. Bulk index via the Query graph on first boot / a one-off admin route.
  3. Subscribers for product.created|updated|deleted → upsert/enrich or soft-delete.
  4. /store/products/search proxying to app.search.

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).