Skip to content

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.

search.ts
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();

Two things matter: don’t let runs overlap, and run maintenance on a slower cadence.

sync.ts
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 minutes
setInterval(syncCatalog, 5 * 60 * 1000);
// Image revalidation + retries every hour
setInterval(runMaintenance, 60 * 60 * 1000);
// Run once on startup so you're not waiting for the first tick
syncCatalog();

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.

When a product changes in your store, push just that row. The next scheduled enrich picks it up:

webhook.ts
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.

In-memory timerDurable queue/workflow
Survives process restartNoYes
Per-step retry (enrich fails, upsert doesn’t re-run)NoYes
Horizontal scale (multiple processes)No — double runsYes — concurrency limits
Observability dashboardYour logsPlatform UI
Extra infraNoneOne service

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