In-memory pipeline (dev and single process)
The simplest way to keep a catalog fresh is a timer in a long-running Node process. No queue, no workflow SDK, no extra accounts. You already have the bundle; you just call it on a schedule.
This is the zero-dependency option. It’s perfect for local dev and fine for a single server you control. It’s not durable across restarts, and it doesn’t survive horizontal scale. When you’re ready for production, graduate to one of the durable platform guides.
Shared bundle setup
Section titled “Shared bundle setup”import { samesake } from "@samesake/postgres";import { llmRerank } from "@samesake/server";import { products } from "./catalog.ts";import { geminiEmbed, geminiGenerate } from "./gemini.ts";
export const app = samesake({ url: process.env.SAMESAKE_DATABASE_URL!, collection: products, models: { embed: geminiEmbed, generate: geminiGenerate }, rerank: llmRerank(geminiGenerate),});
await app.migrate();The sync loop with overlap protection
Section titled “The sync loop with overlap protection”Two things matter: don’t let runs overlap, and run maintenance on a slower cadence.
import { app } from "./search.ts";import { fetchChangedRows } from "./catalog-source.ts";
let syncRunning = false;
async function syncCatalog() { if (syncRunning) { console.log("[sync] skipped — previous run still in progress"); return; } syncRunning = true; try { const rows = await fetchChangedRows(); await app.enrich.upsert(rows); await app.enrich.enrich(); console.log("[sync] done"); } catch (err) { console.error("[sync] failed:", err); } finally { syncRunning = false; }}
async function runMaintenance() { try { // Image revalidation is a caller-owned @samesake/server workflow; retry failed rows here. await app.enrich.retryFailed({ limit: 500 }); console.log("[maintenance] done"); } catch (err) { console.error("[maintenance] failed:", err); }}
// Full loop every 5 minutessetInterval(syncCatalog, 5 * 60 * 1000);
// Image revalidation + retries every hoursetInterval(runMaintenance, 60 * 60 * 1000);
// Run once on startup so you're not waiting for the first ticksyncCatalog();The guard (syncRunning) stops a slow enrich from stacking another full loop on top. Maintenance doesn’t need the same guard — it’s cheap relative to enrich, and the bundle calls are idempotent.
Webhook fast-path (optional)
Section titled “Webhook fast-path (optional)”When a product changes in your store, push just that row. The next scheduled enrich picks it up:
import { app } from "./search.ts";
export async function onProductUpdated(product) { await app.enrich.upsert([product]); // enrich happens on the next cron tick — or call it inline if you need instant search}For near-instant updates, call enrich.enrich() right after enrich.upsert() in the webhook handler. Because the loop is incremental, doing both cron and inline webhook syncs can’t corrupt anything — worst case you do a little extra work.
What you’re trading away
Section titled “What you’re trading away”| In-memory timer | Durable queue/workflow | |
|---|---|---|
| Survives process restart | No | Yes |
Per-step retry (enrich fails, upsert doesn’t re-run) | No | Yes |
| Horizontal scale (multiple processes) | No — double runs | Yes — concurrency limits |
| Observability dashboard | Your logs | Platform UI |
| Extra infra | None | One service |
Where to go next
Section titled “Where to go next”- Why durability matters — Running the enrich pipeline durably
- Row statuses and retries — Pipeline lifecycle
Run it end to end
Section titled “Run it end to end”The runnable, tested version of this guide lives in examples/pipeline-in-memory — deterministic stubs, no API key. Run bun test (Postgres-backed examples need SAMESAKE_DATABASE_URL).