Building a Supabase Cache Proxy

In this tutorial we will build a Spin application that will cache data from Supabase using Key Value store and deploy it to Fermyon Wasm Functions.

Prerequisites

You need to have spin CLI installed on your computer. Please use the official Fermyon Wasm Functions Quickstart to install install Spin and also log in to Fermyon Wasm Functions.

What Is Supabase

Supabase is an open-source Backend-as-a-Service (BaaS) that provides a PostgreSQL database with real-time capabilities, row-level security, and automatic API generation based on your schema. It supports relational data with foreign keys, full-text search, and extensions like PostGIS for geospatial queries. There is a Supabase free tier available, which we will use as part of the tutorial.

Provisioning the Database on Supabase

To sign up for the free tier of Supabase using your GitHub account, go to https://supabase.com and click the Start Your Project button. On the login page, select Continue with GitHub, which will redirect you to GitHub’s authorization page. If you’re not already logged in, enter your GitHub credentials. Grant Supabase the necessary permissions to access your GitHub account. Once authorized, you’ll be redirected to the Supabase dashboard, where you can provide details about your organization and create a new project under the free tier.

Supabase Project Setup

Once your project has been created use the Table Editor and create the articles table by specifying the following columns

Column NameDataTypeDefault ValueNullableDescription
iduuidgen_random_uuid()NoPrimary key (auto generated by database)
created_attimestampnow()NoCreation timestamp of the article
titletextNULLNoTitle of the article
contenttextNULLNoContent of the article
publishedboolfalseNoBoolean flag indicating if article is a draft

When creating the table, make sure to disable Row Level Security (RLS). Although RLS is an amazing feature of Supabase, we won’t use it as part of this tutorial, to keep things as simple.

Table Creation on Supabase

With the articles table created, use the SQL Editor for seeding some sample data to the articles table. Copy the SQL snippet from below and hit the Run button, which will add four new sample articles to the database:

insert into articles(title, content)
values
	('Hello World', 'Welcome to our shiny new blog! ...'),
	('Introducing Fermyon Wasm Functions', 'We are thrilled to announce Fermyon Wasm Functions, ...'),
	('What is Spin', 'This post explains what Spin is and how it increases your productivity ...'),
    ('What is WebAssembly', 'Secure, Portable and Fast! WebAssembly is here to revolutionize ...');

Finding Supabase Project URL and API Key

Navigate to the Home of your Supabase project and find the Connecting to your new project section. From here, copy the Project URL and API Key and keep them in a readily accessible location. We will need both values for authenticating calls from our Spin application towards Supabase in the following section.

Supabase Connection Information

Implementing the Spin Application

With the spin CLI, we can take advantage of its default templates by using the spin new command to create a new Spin application using the http-ts template. Once the application skeleton has been created, we install the @supabase/supabase-js Node.js module as a dependency:

$ spin new -t http-ts -a supabase-proxy
$ cd supabase-proxy
$ npm install @supabase/supabase-js --save

Next, we will update the application manifest (spin.toml) by adding three new variables to grant our application access to the following information:

NameConfigurationDescription
supabase_url{ required = true }The Endpoint of your Supabase project
supabase_key{ required = true, sensitive = true }The Key for authenticating against Supabase
cache_ttl{ default = "5" }Cache Time-To-Live (TTL) in Minutes

Update the spin.toml, and introduce a new variables section to the application manifest using the variables defined above:

[variables]
supabase_url = { required = true }
supabase_key = { required = true, sensitive = true }
cache_ttl = { default = "5" }

With the application variables defined, we must link them to our application component. Additionally, we must allow the component to send outbound requests to supabase_url. Remember that Spin follows a capabilities-based security model, meaning we must explicitly grant the application access to variables and outbound requests. Both actions are achieved using the interpolation syntax:

# Excerpt from the application configuration file (TOML)
# This section defines a component named "supabase-proxy" and links it to application variables.

[component.supabase-proxy]
# Explicitly allow outbound requests to the Supabase URL
allowed_outbound_hosts = [""]

# Link environment variables to the component
[component.supabase-proxy.variables]
supabase_url = ""
supabase_key = ""
cache_ttl = ""

Having everything configured correctly, we will move on and implement the CRUD (Create, Read, Updated, Delete) endpoints of our application.

Implementing HTTP APIs

In this section, we will implement the core HTTP handlers for our application, each responsible for interacting with Supabase. We’ll start by defining middleware to load configuration variables, then set up our HTTP routes, and finally implement handlers for creating, reading, updating, and deleting articles.

Let’s start by creating a middlewares.ts file in the src folder of your application and add the following code:

import { Variables } from "@fermyon/spin-sdk";
import { IRequest } from "itty-router";

/**
 * Application Configuration 
 */
export interface Config {
  url: string,
  key: string,
  cacheTtl: number,
}

/**
  * Middleware to load application configuration
  * An instance of {@link Config} is set on the request
  *
  * @param request The {@link IRequest} automatically passed by itty-router
  * @throws An error if required configuration data is falsy
  */
export function withConfig(request: IRequest) {
  const url = Variables.get('supabase_url');
  const key = Variables.get('supabase_key');
  const ttl = +(Variables.get('cache_ttl') ?? "5");
  if (!url || !key) {
    throw new Error("Required Configuration data not set");
  }

  // set the config on the actual request
  request.config = {
    url,
    key,
    cacheTtl: ttl
  } as Config;
}

Having the withConfig middleware in place, it’s time to layout the HTTP endpoints of our application. Update the src/index.ts file and replace its content with the following code:

import { AutoRouter, json } from 'itty-router';
import { Config, withConfig } from './middlewares';

let router = AutoRouter();

router
  // run the withConfig middleware for every incoming request
  .all('*', withConfig)
  // Create new article
  .post('/articles', async (req) => createArticle(await req.arrayBuffer(), req.config as Config))
  // Read all articles
  .get('/articles', ({ config }) => readArticles(config))
  // Read article by id
  .get('/articles/:id', ({ id, config }) => readArticleById(id, config))
  // Update article by id
  .put('/articles/:id', async (req) => updateArticleById(req.params.id, await req.arrayBuffer(), req.config as Config))
  // Delete article by id
  .delete('/articles/:id', ({ id, config }) => deleteArticleById(id, config));

//@ts-ignore
addEventListener('fetch', async (event: FetchEvent) => {
  event.respondWith(router.fetch(event.request));
});

Let’s implement all handlers that were specified as part of the routes in the previous snippet. Because each handler is going to interact with our Supabase instance, we will bring the createClient function from the @supabase/supabase-js Node.js module in scope by adding the following import statement at the top of the src/index.ts file:

import { createClient } from '@supabase/supabase-js';

With the createClient method in scope, we can implement the handler for reading all articles from Supabase:

const readArticles = async (config: Config): Promise<Response> => {
  // create a Supabase client
  const supabase = createClient(config.url, config.key);
  // load all articles from supabase and sort them by created_at in descending order
  const { data, error } = await supabase.from('articles')
    .select()
    .order('created_at', { ascending: false });
  // Return early with an HTTP 500, if something went wrong
  if (error) {
    console.log(`Error while reading single article: ${error.message} ${error.details} ${error.stack}`);
    return new Response(error.message, { status: 500 });
  }

  // Create an HTTP 200 result and sent all articles as JSON
  return json(data, { status: 200 });
}

Reading a single article is similar to reading all articles from Supabase. However, we may return an HTTP 404 (Not Found) if no article exists with the provided identifier in the route parameter (:id):

const readArticleById = async (id: string, config: Config): Promise<Response> => {
  // create a Supabase client
  const supabase = createClient(config.url, config.key);
  // load a single article using it's identifier
  // use the maybeSingle function, indicating that there might be no match at all
  const { data, error } = await supabase.from('articles')
    .select()
    .eq('id', id)
    .maybeSingle();

  // Return early with an HTTP 500, if something went wrong
  if (error) {
    console.log(`Error while reading single article: ${error.message} ${error.details} ${error.stack}`);
    return new Response(error.message, { status: 500 });
  }
  // Return early with an HTTP 404, if no article was found
  if (!data) {
    return new Response("Not Found", { status: 404 });
  }
  
  // Create an HTTP 200 result and sent the article as JSON
  return json(data, { status: 200 });
}

Users must sent a JSON object with title and content as payload for creating a new article. The implementation of createArticle validates the request payload and uses the Supabase API for inserting a new row into the articles table:

const createArticle = async (requestBody: ArrayBuffer, config: Config): Promise<Response> => {
  let payload;
  try {
    // create a new TextDecoder
    const decoder = new TextDecoder();
    // try to parse the request payload
    payload = JSON.parse(decoder.decode(requestBody));
  } catch (error) {
    // return early with an HTTP 400, in case malformatted data is provided
    return new Response("Bad Request", { status: 400 });
  }
  
  // if the provided payload does not specify title or content, return with an HTTP 400
  if (!payload.title || !payload.content) {
    return new Response("Bad Request", { status: 400 });
  }

  const supabase = createClient(config.url, config.key);

  // insert the article into the database
  const { data, error } = await supabase
    .from('articles')
    .insert({ title: payload.title, content: payload.content })
    .select()
    .single();
  // return early with HTTP 500, in case of any error
  if (error) {
    console.log(`Error while storing article in database: ${error.message} ${error.details} ${error.stack}`);
    return new Response('Internal Server Error', { status: 500 });
  }
  
  // return the new article with an HTTP 201
  return json(data, { status: 201 });
}

As part of the updateArticleById function, we must deal with callers providing an identifier (id) of a non-existing article. Luckily the fluent API of Supabase streamlines this as well:

const updateArticleById = async (id: string, requestBody: ArrayBuffer, config: Config): Promise<Response> => {
  let payload;
  try {
    const decoder = new TextDecoder();
    payload = JSON.parse(decoder.decode(requestBody));
  } catch (error) {
    return new Response("Bad Request", { status: 400 });
  }

  if (!payload.title || !payload.content) {
    return new Response("Bad Request", { status: 400 });
  }

  const supabase = createClient(config.url, config.key);
  const { data, error } = await supabase
    .from('articles')
    .update({ title: payload.title, content: payload.content, published: payload.published })
    .eq('id', id)
    .select()
    .maybeSingle();

  if (error) {
    console.log(`Error while updating article in database: ${error.message} ${error.details} ${error.stack}`);
    return new Response('Internal Server Error', { status: 500 });
  }
  if (!data) {
    return new Response('Not Found', { status: 404 });
  }
  return json(data, { status: 200 });
}

The deleteArticleById is the last handler we have to implement, again we receive the identifier of an article from the corresponding route parameter (:id) and leverage the Supabase API to delete the record from the articles table. We can check response.count to determine if a record was found with matching identifier or not:

const deleteArticleById = async (id: string, config: Config): Promise<Response> => {
  const supabase = createClient(config.url, config.key);
  const response = await supabase.from('articles').delete().eq('id', id);
  if (response.error) {
    console.log(`Error while deleting article from database: ${response.error.message} ${response.error.details} ${response.error.stack}`);
    return new Response('Internal Server Error', { status: 500 });
  }
  if (response.count == 0) {
    return new Response('Not Found', { status: 404 });
  }
  return new Response(null, { status: 204 });
}

At this point, we have a fully functional application. It interacts directly with the Supabase instance for reading and writing data. In the next section of the tutorial, we will add the caching capabilities to our application, for optimizing latency while delivering data for recurring requests.

Implementing a Cache Using Key Value Store

Applications deployed to Fermyon Wasm Functions could leverage a no-ops Key-Value store. The platform will provision and manage the Key Value store for you. All you’ve to do is specifying your intent of using a Key Value store. To do so, update the application manifest (spin.toml) and add a key_value_stores property to the component configuration as shown in the following snippet:

[component.supabase-proxy]
key_value_stores = ["default"]

For every Key Value store, you must specify a name. The name default is special, because Spin SDKs provide convenient methods for accessing the default Key Value store. If you chose a name other than default, you must use corresponding methods of the Spin SDK and provide the chosen name as argument.

Now that we have granted our component access to the default Key Value store, it’s time to implement caching capabilities. The cache implementation should address the following concerns:

  • Persist individual articles in the cache, to allow retrieval using an article identifier (id)
  • Persist a list of all articles in the cache using a consistent key (e.g. all-articles)
  • All data persisted in the cache should have an expiration timestamp taking the cache_ttl configuration data into context
  • Cache invalidation should happen whenever data is added, updated or removed from the cache

A note on cache performance expectations: Fermyon Wasm Functions’ Key Value store does not currently support geo-replication in public preview. This means that cache performance may vary depending on where requests originate. While this works well for many use cases, some latency-sensitive applications may experience differences in response times. We welcome feedback on how this impacts your experience so we can improve the service in future releases.

We’ll use the Kv APIs provided by the Spin SDK tor interacting with the Key Value store. Place the following code in the new src/cache.ts file:

import { Kv } from "@fermyon/spin-sdk";

// CacheData is a wrapper for data stored in the key value store
// it defines when data will expire
interface CacheData {
  expiresAt: string,
  data: any
}

// Key for caching the list of all articles
export const ALL_ARTICLES_CACHE_KEY = "all-articles";

// function to compute the cache key for a particular article using its identifier
export function buildKey(id: string): string {
  return `article-${id}`;
}

// read a value from the cache
// returns either the cached value or undefined if key is unknown, or data is expired
export function readFromCache(key: string): any | undefined {
  console.log(`Reading from Cache with key: ${key}`);
  const store = Kv.openDefault();
  // return early with `undefined` if key is not known
  if (!store.exists(key)) {
    return undefined;
  }
  // load CacheData from the key value store
  const cacheData = store.getJson(key);
  // return either cached data or undefined if data is expired
  return onlyValidCacheData(cacheData);
};

// store value for ttl in the cache at key
export function storeInCache(key: string, value: any, ttl: number) {
  console.log(`Storing data in Cache at ${key}`);
  const store = Kv.openDefault();
  // create a CacheData instance and store it at position key
  store.setJson(key, buildCacheData(value, ttl));
  // because we modified the underlying data storage, we must check
  // if the list of all articles is in cache, if so, remove it
  if (key !== ALL_ARTICLES_CACHE_KEY && store.exists(ALL_ARTICLES_CACHE_KEY)) {
    console.log(`Invalidating data from Cache at ${ALL_ARTICLES_CACHE_KEY}`);
    store.delete(ALL_ARTICLES_CACHE_KEY);
  }
}

// remove item at position key from the cache
export function invalidate(key: string) {
  console.log(`Invalidating data from Cache at ${key}`);
  const store = Kv.openDefault();
  // if key exists, remove the cached data 
  if (store.exists(key)) {
    store.delete(key);
  }
  // if a particular article was updated / removed from the cache
  // we must check and remove the list of all articles as well
  if (key !== ALL_ARTICLES_CACHE_KEY && store.exists(ALL_ARTICLES_CACHE_KEY)) 
    console.log(`Invalidating data from Cache at ${ALL_ARTICLES_CACHE_KEY}`);
    store.delete(ALL_ARTICLES_CACHE_KEY);
  }
}

// creates a new instance of CacheData
// ttl defines when data will expire
const buildCacheData = (data: any, ttl: number): CacheData => {
  return {
    expiresAt: new Date(Date.now() + ttl * 60 * 1000).toISOString(),
    data: data
  } as CacheData;
}

// If cacheItem is expired, this function will return `undefined`
// otherwise, the containing data will be returned to the callee
const onlyValidCacheData = (cacheItem: CacheData): any | undefined => {
  const now = new Date();
  const expiresAt = new Date(cacheItem.expiresAt);
  if (now > expiresAt) {
    return undefined;
  }
  return cacheItem.data;
}

Having all caching capabilities implemented, we must integrate it with the existing CRUD handlers. Before modifying each handler function, bring necessary constants and functions into scope by adding the following line at the beginning of src/index.ts:

import { 
	ALL_ARTICLES_CACHE_KEY, 
	buildKey, 
	invalidate, 
	readFromCache, 
	storeInCache 
} from './cache';

Again, let’s cycle through all the handlers and update their implementation as shown in the snippets below. Start with the readArticles handler responsible for returning the list of all articles:

const readArticles = async (config: Config): Promise<Response> => {
  const cached = readFromCache(ALL_ARTICLES_CACHE_KEY);
  if (cached) {
    return json(cached, { status: 200, headers: { 'x-served-via-cache': 'true' } });
  }

  // existing code from readArticles ...

  storeInCache(ALL_ARTICLES_CACHE_KEY, data, config.cacheTtl);
  return json(data, { status: 200 });
}

Reading a particular article using it’s identifier is next:

const readArticleById = async (id: string, config: Config): Promise<Response> => {
  const cacheKey = buildKey(id);
  const cached = readFromCache(cacheKey);
  if (cached) {
    return json(cached, { status: 200, headers: { 'x-served-via-cache': 'true' } });
  }

  // existing code from readArtilceById
	
  storeInCache(cacheKey, data, config.cacheTtl);
  return json(data, { status: 200 });
}

When creating a new article, we must ensure it’s added to the cache and the list of all articles must be invalidated:

const createArticle = async (requestBody: ArrayBuffer, config: Config): Promise<Response> => {
 
  // existing code from createArticle ...

  const cacheKey = buildKey(data.id);
  storeInCache(cacheKey, data, config.cacheTtl);
  return json(data, { status: 201 });
}

Updating an existing article should store the updated article in the cache and invalidate the list of all articles in the cache as well:

const updateArticleById = async (id: string, requestBody: ArrayBuffer, config: Config): Promise<Response> => {

  // existing code from updateArticleById ...

  storeInCache(buildKey(id), data, config.cacheTtl)
  return json(data, { status: 200 });
}

Finally, removing an article from the database should remove it from the cache as well and invalidate the list of all articles:

const deleteArticleById = async (id: string, config: Config): Promise<Response> => {

  // existing code from deleteArticleById ...

  invalidate(buildKey(id))
  return new Response(null, { status: 204 });
}

Deploying to Fermyon Wasm Functions

To deploy the application to Fermyon Wasm Functions, use the spin aka deploy command. Necessary application variables (supabase_url and supabase_key) must be passed using the --variable flag:

$ spin aka deploy --variable supabase_url="<YOUR_SUPABASE_URL>" \
    --variable supabase_key="<YOUR_SUPABASE_KEY>"
App 'supabase-proxy' initialized successfully.
Waiting for application to be ready... ready

View application:   https://b4208b18-5dbf-4408-8555-5779881b7207.aka.fermyon.tech/

Testing the Application

Let’s test the application by sending arbitrary requests to the app with curl.

$ export APP_URL=https://b4208b18-5dbf-4408-8555-5779881b7207.aka.fermyon.tech

$ curl -iX GET $APP_URL/articles

For the first invocation, we expect to see the list of articles. Double-check the response headers, we shall not see a x-served-by-cache header for the first invocation.

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
x-envoy-upstream-service-time: 739
Server: envoy
Date: Thu, 13 Feb 2025 15:55:47 GMT
Content-Length: 1090
Connection: keep-alive
Set-Cookie: akaalb_fwf-prod-apps=~op=fwf_prod:fwf-apps-fr-par|~rv=75~m=fwf-apps-fr-par:0|~os=1231e1ede8704e97468b2ddc2c84cd5b~id=b666132848cbc51812be74811328308e; path=/; HttpOnly; Secure; SameSite=None
Akamai-GRN: 0.0b4f1002.1739462146.bceb86b

[{"id":"57d636ad-b5d3-40a4-8b15-34326fc4553b","created_at":"2025-02-13T14:18:47.358804+00:00","title":"Another hello 3","content":"Hey I am also here as an author","published":false},{"id":"3a7cc280-7684-4c16-ada4-eba4c29fd486","created_at":"2025-02-12T09:54:07.655385+00:00","title":"The impressive benefits of usign WebAssembly on the Client","content":"In this article we will explore the benefits of using WebAssembly on the client","published":false},{"id":"d126fc4d-e360-4cbb-964d-ef4457e51009","created_at":"2025-02-12T09:52:37.457362+00:00","title":"The impressive benefits of usign WebAssembly in the Cloud","content":"In this article we will explore the benefits of using WebAssembly in the Cloud","published":false},{"id":"65d1f1e4-88f4-4823-8e78-2604056f283b","created_at":"2025-02-11T15:49:28.462493+00:00","title":"How to Learn X in Y Minutes","content":"How to learn tech topics fast","published":false},{"id":"82737e74-786b-4544-b1dd-91ed6e024422","created_at":"2025-02-11T15:49:07.30648+00:00","title":"Hello World","content":"This is our first article","published":false}]

Sending a secondary request to the same endpoint, should return the same response payload, however this time, we expect the x-served-by-cache header to be part of the response:

$ curl -iX GET $APP_URL/articles

Which should generate an output similar to this:

HTTP/1.1 200 OK
x-served-via-cache: true
Content-Type: application/json; charset=utf-8
x-envoy-upstream-service-time: 440
Server: envoy
Date: Thu, 13 Feb 2025 15:55:49 GMT
Content-Length: 1090
Connection: keep-alive
Set-Cookie: akaalb_fwf-prod-apps=~op=fwf_prod:fwf-apps-fr-par|~rv=11~m=fwf-apps-fr-par:0|~os=1231e1ede8704e97468b2ddc2c84cd5b~id=34c25fe9dc812b3647e5b7090035a7fc; path=/; HttpOnly; Secure; SameSite=None
Akamai-GRN: 0.0a4f1002.1739462148.a574fbf

[{"id":"57d636ad-b5d3-40a4-8b15-34326fc4553b","created_at":"2025-02-13T14:18:47.358804+00:00","title":"Another hello 3","content":"Hey I am also here as an author","published":false},{"id":"3a7cc280-7684-4c16-ada4-eba4c29fd486","created_at":"2025-02-12T09:54:07.655385+00:00","title":"The impressive benefits of usign WebAssembly on the Client","content":"In this article we will explore the benefits of using WebAssembly on the client","published":false},{"id":"d126fc4d-e360-4cbb-964d-ef4457e51009","created_at":"2025-02-12T09:52:37.457362+00:00","title":"The impressive benefits of usign WebAssembly in the Cloud","content":"In this article we will explore the benefits of using WebAssembly in the Cloud","published":false},{"id":"65d1f1e4-88f4-4823-8e78-2604056f283b","created_at":"2025-02-11T15:49:28.462493+00:00","title":"How to Learn X in Y Minutes","content":"How to learn tech topics fast","published":false},{"id":"82737e74-786b-4544-b1dd-91ed6e024422","created_at":"2025-02-11T15:49:07.30648+00:00","title":"Hello World","content":"This is our first article","published":false}]

Bonus: Cache Invalidation upon External Data Modifications

Chances are pretty high, that other applications will also modify data in the Supabase database. Luckily, we could leverage the Integration capabilities provided by Supabase, to invalidate our cache whenever data in the database is modified using either the Supabase portal or any other third party application.

Open the Supabase portal, navigate to your project and open the Integrations page. From here select Postgres Modules and click the Database Webhooks button from the database page, find and click the Webhooks button.

Enable Database Webhooks

Database Webhooks must be enabled once per project, click the Enable Webhooks button to do so. Once enabled, navigate to the Webhooks tab and click the Create a new hook button.

Webhooks in Supabase offer quite some configuration capabilities, configure the new webhook using the following values:

PropertyValueDescription
NameCall Spin App on Fermyon Wasm FunctionsUnique Name for your webhook
Conditions to fire webhookTable: articles Events: Insert, Update, DeleteDefine the scope of this webhook (articles table) and ensure all event types are enabled
Webhook configurationHTTP RequestTell Supabase to send a HTTP request for events
HTTP Request MethodPOSTSupabase should sent POST requests to our Spin App
HTTP Request URL<YOUR_APP_URL>/informProvide the URL of your Spin application (You received it when deploying the app using spin aka deploy) . Ensure to add the /inform suffix
HTTP Headerscontent-type: application/json x-webhook-token: <some_value>Add a new HTTP header called x-webhook-token and set its value to a random value (e.g. a new UUID). Note down this value, as we need this when updating the app on Fermyon Wasm Functions

Once you have customized all the properties of the webhook, click Create Webhook.

With the webhook configured in Supabase, we have to update our Spin application. Let’s introduce a new application variable called supabase_webhook_token and grant our application component access to the variable.

[variables]
# ...
supabase_webhook_token = { required = true, secret = true }

# ...

[component.supabase-proxy.variables]
# ...
supabase_webhook_token = ""

Update the Config interface and the withConfig function in src/middlewares.ts to load and expose the webhookToken:

import { Variables } from "@fermyon/spin-sdk";
import { IRequest } from "itty-router";

export interface Config {
  url: string,
  key: string,
  cacheTtl: number,
  webhookToken: string,
}

export function withConfig(request: IRequest) {
  const url = Variables.get('supabase_url');
  const key = Variables.get('supabase_key');
  const ttl = +(Variables.get('cache_ttl') ?? "5");
  const webhookToken = Variables.get('supabase_webhook_token');

  if (!url || !key || !webhookToken) {
    throw new Error("Required Configuration data not set");
  }
  request.config = {
    url,
    key,
    cacheTtl: ttl,
    webhookToken
  } as Config;
}

Supabase will send necessary information about the event and affected records as payload of every webhook invocation. Although we could take that information into context, and provide an even more sophisticated implementation, let’s keep it simple for now. Depending on the actual event (INSERT, UPDATE, or DELETE), we’ll either:

  • Remove the list of all articles from the cache (on INSERT)
  • Remove the item of the affected article and the list of all articles from the cache (on UPDATE and DELETE)

Again, let’s encapsulate the implementation, add a new TypeScript file at src/inform.ts and add the following content:

import { ALL_ARTICLES_CACHE_KEY, buildKey, invalidate } from "./cache"

interface InformPayload {
  type: string
  table: string
  record: ArticleLike | null,
  schema: string
  old_record: ArticleLike | null
}

interface ArticleLike {
  id: string
}

export function processDatabaseUpdate(requestBody: ArrayBuffer): Response {
  let payload;

  try {
    const decoder = new TextDecoder();
    payload = JSON.parse(decoder.decode(requestBody)) as InformPayload;
  }
  catch (error) {
    return new Response('Bad Requst', { status: 400 });
  }
  switch (payload.type.toLowerCase()) {
    case "insert":
      invalidate(ALL_ARTICLES_CACHE_KEY);
      return new Response(null, { status: 200 });
    case "update":
      invalidate(buildKey(payload.record!.id));
      return new Response(null, { status: 200 });
    case "delete":
      invalidate(buildKey(payload.old_record!.id));
      return new Response(null, { status: 200 });
    default:
      return new Response('Bad Request', { status: 400 });
  }
}

Finally, we could update src/index.ts to bring the processDatabaseUpdate function into scope, add a new route to the router instance, and call the processDatabaseUpdate function from within a new handler (onDatabaseUpdate). As part of this, we’ll also double-check if incoming requests have the x-webhook-tokenheader specified and pass the correct value:

import  processDatabaseUpdate  from './inform';

// ..

router // ...
  .post("/inform", async (req) => onDatabaseUpdate(req.headers, await req.arrayBuffer(), req.config as Config));

const onDatabaseUpdate = (headers: Headers, requestBody: ArrayBuffer, config: Config): Response => {
  const token = headers.get("x-webhook-token");
  if (!token || token !== config.webhookToken) {
    console.log("Webhook invoked without or with invalid token")
    return new Response(null, { status: 401 });
  }
  return processDatabaseUpdate(requestBody)
};

With all those changes in place, you can save the modifications, compile your source code to Wasm (spin build) and re-deploy the application to Fermyon Wasm Functions. In contrast to the initial deployment, we’ve to add the supabase_webhook_token variable and provide the value specified when creating the webhook over on Supabase:

$ spin aka deploy --variable supabase_url="\<YOUR_SUPABASE_URL\>" \
    --variable supabase_key="<YOUR_SUPABASE_KEY>" \
    --variable supabase_webhook_token="<YOUR_TOKEN>"
Waiting for application to be ready... ready

View application:   https://b4208b18-5dbf-4408-8555-5779881b7207.aka.fermyon.tech/

You can use the Supabase portal to insert, update, or delete records from the articles table. On the flip side use the spin aka logs command, to retrieve logs from your Spin application running on Fermyon Wasm Functions. The cache invalidation implementation will generate log messages that you could use to verify cache busting also works for database chances done by third party applications now.

$ spin aka logs
2025-02-13 14:50:54 [supabase-proxy]  Storing data in Cache at all-articles
2025-02-13 14:50:54 [supabase-proxy]  Reading from Cache with key: all-articles
2025-02-13 14:51:17 [supabase-proxy]  Invalidating data from Cache at article-46d94d5d-28db-47b8-b901-95bb0962705d
2025-02-13 14:51:17 [supabase-proxy]  Invalidating data from Cache at all-articles

Summary

By completing this tutorial, you have successfully built and deployed a Spin application that caches data from Supabase using the no-ops Key Value Store powered by Fermyon Wasm Functions. Here’s what you’ve accomplished:

  • Set Up Supabase – You provisioned a Supabase database, created a table for articles, and seeded it with sample data.
  • Developed a Spin App – You built a web service using Spin with TypeScript, implementing CRUD endpoints for managing articles.
  • Integrated Supabase – You connected your app to Supabase using the @supabase/supabase-js client to fetch and manipulate data.
  • Implemented Caching – You optimized performance by adding a caching layer with Spin’s Key Value store, reducing redundant database queries.
  • Enabled Cache Invalidation – You ensured data consistency by implementing cache invalidation when articles were added, updated or deleted.
  • Deployed to Fermyon Wasm Functions – You successfully deployed your application and tested it using curl, confirming that caching works as expected.
  • Added Webhook Support – You leveraged Supabase Database webhooks to detect external data modifications and automatically invalidate the cache.

Now, your application is optimized for fast and efficient data retrieval while maintaining real-time consistency.

Great job! 🚀

You can find the source code of the app built as part of this tutorial in the Fermyon Wasm Functions example repository