Back to blog

Building a Local-First Decentralized Database with IPCM and PGLite
Databases are one of the few pieces of infrastructure that have not been completely conquered in the blockchain space, and for a lot of good reasons. Trying to reinvent databases and how they can be stored onchain doesn’t generally end well. However, with our creation of IPCM a few months ago, it did start to make us think of a different approach. We’ve done plenty of blog posts on PGLite as it’s a great in-memory Postgres database library and makes it easy to accomplish local-first software. You can even dump the database file and upload it to IPFS and create a backup of your database which can be pretty handy, but what if you could make it work like a sync engine? That’s essentially what we’ve built using IPCM, including its own layer of onchain authentication and minimal CRDT using block timestamps! In this post, we’ll do a brief overview of how we did it, where it succeeded, but also where it fell short.
Contract
We started with a simple IPCM contract, and if you’re not familiar with that term, it stands for InterPlanetary CID Mapping, and it provides a simple way to reference and track dynamic IPFS content onchain. It holds a CID state which can be viewed or updated by authorized parties, and each update is recorded onchain providing a version history. For the IPCM DB we did a small tweak by adding AccessControl.sol
from Open Zeppelin.
// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts ^5.0.0
pragma solidity ^0.8.23;
import {AccessControl} from "dependencies/@openzeppelin-contracts-5.3.0-rc.0/access/AccessControl.sol";
contract IPCM is AccessControl {
string private cidMapping;
bytes32 public constant EDITOR_ROLE = keccak256("EDITOR_ROLE");
constructor() {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(EDITOR_ROLE, msg.sender);
}
event MappingUpdated(string value);
function updateMapping(string memory value) public onlyRole(EDITOR_ROLE) {
cidMapping = value;
emit MappingUpdated(value);
}
function getMapping() public view returns (string memory) {
return cidMapping;
}
}
With this approach, the owner of the contract can grant an EDITOR_ROLE
to any wallet address, giving them access to update the CID mapping. This means that we can have a set of users that have permission to update the database. It’s not as good as RLS (Row Level Security), but it’s a start!
Client
Now that we have our contract, which will handle our database state and authorization, we can build a client to actually use the database. Behind the scenes, we also have a simple Hono API on a Cloudflare Worker that creates presigned URLs which you can read more about here. In our example, we did a simple To-Do app that uses PGLite locally for fast reads and writes. When a database needs to be saved and synced with the contract, we create a special manifest of JSON data.
import { publicClient } from "./config";
export interface DbVersionManifest {
cid: string;
timestamp: number;
blockNumber: number;
blockTimestamp: number;
prevVersion: string | null;
changeLog: string[];
signature?: string; // Optional: Add user signature for additional verification
}
export async function createVersionManifest(
cid: string,
prevCid: string | null,
changes: string[] = []
): Promise<DbVersionManifest> {
// Get the latest block for timestamp
const latestBlock = await publicClient.getBlock();
return {
cid,
timestamp: Date.now(),
blockNumber: Number(latestBlock.number),
blockTimestamp: Number(latestBlock.timestamp),
prevVersion: prevCid,
changeLog: changes,
};
}
It includes some basic info like the CID of the current version, but also items like the timestamp
, blockNumber
, and blockTimestamp
which provides a reference we can use to help solve conflict resolution. Here is a rough example of how we might do that:
// Simple conflict resolution based on blockchain timestamp
if (!dbExists) {
// No local DB, use remote
console.log("No local database found, creating from remote");
db = new PGlite({
loadDataDir: dbFile,
dataDir: "idb://todo-db",
});
setCurrentVersion(remoteManifest);
await saveLocalVersion(remoteManifest);
setHasLocalChanges(false);
} else if (!localVersion) {
// We have local DB but no version info - clean up both possible DBs to avoid conflicts
console.log("Local database found but no version info, clearing databases");
await clearDatabase("todo-db");
// Create from remote instead
db = new PGlite({
loadDataDir: dbFile,
dataDir: "idb://todo-db",
});
setCurrentVersion(remoteManifest);
await saveLocalVersion(remoteManifest);
setHasLocalChanges(false);
} else if (localVersion.cid === remoteManifest.cid) {
// Same version, use local but ensure we're using the right path
console.log("Local version matches remote, using local");
// Clear alt path if it exists to prevent conflicts
if (dbExistsAlt) {
await clearDatabase("todo-db");
}
db = new PGlite({
dataDir: "idb://todo-db",
});
setCurrentVersion(localVersion);
setHasLocalChanges(false);
} else {
// Conflict resolution based on timestamp
const useRemote = remoteManifest.blockTimestamp > (localVersion.blockTimestamp || 0);
if (useRemote) {
// Use remote version - clear both paths first
console.log("Remote version is newer, clearing both DBs");
await clearDatabase("todo-db");
db = new PGlite({
loadDataDir: dbFile,
dataDir: "idb://todo-db",
});
setCurrentVersion(remoteManifest);
await saveLocalVersion(remoteManifest);
setHasLocalChanges(false);
toast("Using newer remote version");
} else {
// Keep local version but ensure we're using the right path
console.log("Local version is newer, using local");
db = new PGlite({
dataDir: "idb://todo-db",
});
setCurrentVersion(localVersion);
setHasLocalChanges(true);
toast("Using local version");
}
}
Here’s a breakdown of the logic:
- If no local database exists, the app will initialize a new database using the remote manifest.
- If a local database exists, but there’s no version information, it will clear any database instances to avoid conflicts and create a new database using the remote manifest.
- If the local database and the remote version have the same CID, then it means they’re identical and we don’t have to do anything.
- If there is a local database with a version, as well as a remote database with different CIDs, then we use the block timestamps to determine the latest version and go with that as our updated database.
In the end, we get an app that can start up a new database, save it onchain, and handle any other versions that might come and sync them with clients.
Wrapping Up
Of course, there are multiple limitations with this approach. One of them is that it’s not a pure CRDT solution. Here we are using a simpler LWW (Last Write Wins) path to handle conflicts, so you could have a scenario where a user overwrites someone else’s unsaved changes on another machine. To have a more robust solution, we could build out a merge operation to combine database changes instead of overwriting them completely.
Another annoyance is that every database save has to be signed by a blockchain wallet and costs gas to do. With Base, these updates are less than a penny, but depending on the setup you could have people signing with a wallet extension which gets redundant, or you might require people having a local private key which isn’t secure.
Despite its limitations we are seeing more and more possibilities with using IPCM and IPFS for different layers of apps and architecture. With enough time and persistence I believe we can see a future where IPFS is used for any type of file as it was originally intended, so all data can be content addressable and portable for an open web. Pinata wants to see that come true and our work today continues to make IPFS simple and easy for developer, so be sure to try it out today!
Happy Pinning!