Skip to content

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.

Terminal window
bun add @porulle/core @porulle/adapter-postgres @samesake/core @samesake/postgres

1. 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:

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

to-doc.ts
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 ?? "",
},
};
}

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:

bulk-index.ts
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();
}

Porulle entity config supports lifecycle hooks (afterCreate, afterUpdate, afterDelete, …). Hang the sync off them so the index tracks catalog mutations with no extra infrastructure:

commerce.config.ts
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.)

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 });
  1. One samesake() bundle with the products collection (same DB as Porulle, or its own).
  2. Bulk index the active catalog via /api/catalog/entities (or the in-process kernel) on first boot.
  3. Sync via afterCreate/afterUpdate/afterDelete hooks on the product entity.
  4. 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.

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