Porulle
Porulle is a TypeScript-first, self-host headless commerce framework. Because both Porulle and samesake are TypeScript libraries you embed (not hosted services), they compose in one app — and, if you like, one Postgres database. This guide wires samesake search onto a Porulle catalog: index once, then sync on catalog changes.
For a complete, runnable build (Next.js storefront + live demo), see Fashion app with Porulle + Next.js.
Install
Section titled “Install”bun add @porulle/core @porulle/adapter-postgres @samesake/core @samesake/postgres1. Map the Porulle product to a collection
Section titled “1. Map the Porulle product to a collection”A Porulle product is a sellable_entity with localized attributes (title/description), pricing, and declared customFields. Mirror what you want searchable/filterable:
import { collection, f, Channels } from "@samesake/core";
export const products = collection("products", { fields: { title: f.text({ searchable: true }), brand: f.text({ filterable: true }), category: f.text({ filterable: true }), color: f.text({ filterable: true }), material: f.text({ filterable: true }), price: f.number({ filterable: true, budget: true }), available: f.boolean({ filterable: true }), image_url: f.text(), }, enrich: { stages: [] }, indexing: { surfaces: { embed_doc: { kind: "dense", embedding: "doc", build: ({ data }) => `${data.title} ${data.brand} ${data.category} ${data.color} ${data.material}`.trim(), }, fts_doc: { kind: "fts", build: ({ data }) => `${data.title} ${data.brand} ${data.category} ${data.color} ${data.material}`.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", "brand", "category", "color", "material"], weight: 1 }), Channels.cosine({ embedding: "doc", weight: 1 }), ], combiner: "rrf", },});A mapper turns a hydrated Porulle entity into a samesake document. Title comes from attributes, price from pricing (base price = the variant_id IS NULL row), apparel attributes from customFields:
export function toDoc(e: any) { const attr = e.attributes?.find((a: any) => a.locale === "en") ?? e.attributes?.[0]; const base = e.pricing?.find((p: any) => p.currency === "LKR"); return { id: e.slug, data: { title: attr?.title ?? "", brand: String(e.metadata?.brand ?? "unknown"), category: e.customFields?.category ?? "other", color: e.customFields?.color ?? "", material: e.customFields?.material ?? "", price: base?.amount ?? 0, available: e.status === "active", image_url: e.customFields?.imageUrl ?? "", }, };}2. Bulk index once
Section titled “2. Bulk index once”Read the live catalog from Porulle’s REST API (x-api-key auth), paging until you’ve covered meta.pagination.total, and include the relations you map:
import type { SamesakeBundle } from "@samesake/postgres";
async function bulkIndex(base: string, apiKey: string, app: SamesakeBundle) { await app.migrate(); for (let page = 1; ; page++) { const res = await fetch( `${base}/api/catalog/entities?status=active&include=attributes,pricing&limit=100&page=${page}`, { headers: { "x-api-key": apiKey } } ); const { data, meta } = await res.json(); if (!data.length) break; await app.enrich.upsert(data.map(toDoc)); if (page >= meta.pagination.totalPages) break; } await app.enrich.enrich();}3. Keep in sync with entity hooks
Section titled “3. Keep in sync with entity hooks”Porulle entity config supports lifecycle hooks (afterCreate, afterUpdate, afterDelete, …). Hang the sync off them so the index tracks catalog mutations with no extra infrastructure:
import { app } from "./samesake"; // your samesake() bundle + products collection
export default defineConfig({ // … entities: { product: { fields: [ /* material, color, category, imageUrl */ ], hooks: { afterCreate: [async (ctx) => { await app.enrich.upsert([toDoc(ctx.entity)]); await app.enrich.enrich(); }], afterUpdate: [async (ctx) => { await app.enrich.upsert([toDoc(ctx.entity)]); await app.enrich.enrich(); }], afterDelete: [async (ctx) => { await app.enrich.remove([ctx.entity.slug]); }], }, }, },});(If you’d rather decouple, Porulle’s webhooks module can POST catalog events to a samesake-sync endpoint instead — same shape as the Shopify / WooCommerce webhook flows.)
4. Serve search
Section titled “4. Serve search”Expose a search route that proxies to samesake — in Next.js that’s one route handler; under Porulle’s Hono app it’s a custom route:
const result = await app.search(q, { filters: { available: true }, limit: 24 });Recommended shape
Section titled “Recommended shape”- One
samesake()bundle with theproductscollection (same DB as Porulle, or its own). - Bulk index the active catalog via
/api/catalog/entities(or the in-process kernel) on first boot. - Sync via
afterCreate/afterUpdate/afterDeletehooks on the product entity. - A search route calling
app.search.
See the full Next.js build for a working end-to-end example with a storefront and a live demo.
Run it end to end
Section titled “Run it end to end”The runnable, tested version of this guide lives in examples/porulle-sync — deterministic stubs, no API key. Run bun test (Postgres-backed examples need SAMESAKE_DATABASE_URL).