Offline-first guide
ExpressX supports offline-first operation: reads and writes succeed immediately against a local IndexedDB cache, and data is synchronised with the server automatically when the connection is (re-)established.
This requires three packages:
@jcbuisson/express-x— server framework@jcbuisson/express-x-drizzle— server-side Drizzle ORM integration@jcbuisson/express-x-client— client library with IndexedDB cache
Database schema requirements
Every model that participates in offline sync must:
- Have a
uid TEXT PRIMARY KEYcolumn (a UUIDv7 generated client-side) - Share a single
metadatatable that tracks creation, update, and soft-delete timestamps
CREATE TABLE metadata (
uid TEXT PRIMARY KEY,
created_at TIMESTAMP,
updated_at TIMESTAMP,
deleted_at TIMESTAMP
);
CREATE TABLE post (
uid TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT
);Using Drizzle ORM:
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core'
export const metadata = pgTable('metadata', {
uid: text('uid').primaryKey(),
created_at: timestamp(),
updated_at: timestamp(),
deleted_at: timestamp(),
})
export const post = pgTable('post', {
uid: text('uid').primaryKey(),
title: text('title').notNull(),
content: text('content'),
})Server setup
Install the Drizzle plugin:
npm install @jcbuisson/express-x-drizzle drizzle-orm// app.js
import { expressX } from '@jcbuisson/express-x'
import { drizzleOfflinePlugin } from '@jcbuisson/express-x-drizzle'
import { drizzle } from 'drizzle-orm/node-postgres'
import { metadata, post } from './schema.js'
const app = expressX()
const db = drizzle(process.env.DATABASE_URL)
// creates services and the 'sync' service for each listed model
drizzleOfflinePlugin(app, db, metadata, [post])
app.httpServer.listen(8000)drizzleOfflinePlugin(app, db, metadataTable, models) creates one service per model (named after the table), and a shared sync service. Each model service exposes:
| Method | Description |
|---|---|
findUnique(where) | Return first matching row or null |
findMany(where) | Return all matching rows |
createWithMeta(uid, data, created_at) | Insert row + metadata, return [value, meta] |
updateWithMeta(uid, data, updated_at) | Update row + metadata, return [value, meta] |
deleteWithMeta(uid, deleted_at) | Delete row, soft-delete metadata, return [value, meta] |
where clause
where is a plain object used for both server-side (Drizzle) and client-side (IndexedDB) filtering. Supported operators:
{ field: value } // equality
{ field: null } // IS NULL
{ field: { gte: 1 } } // range — gte, gt, lte, lt can be combined
{ field: { gte: 1, lte: 10 } }Publishing sync events
For real-time updates to propagate to other connected clients, publish the events for each model service:
app.addConnectListener((socket) => {
app.joinChannel('all', socket)
})
app.service('post').publish(async (context) => ['all'])Client setup
Install:
npm install @jcbuisson/express-x-client dexie rxjs uuid @vueuse/coreimport { io } from 'socket.io-client'
import { createClient, offlinePlugin } from '@jcbuisson/express-x-client'
const socket = io('http://localhost:8000', { transports: ['websocket'] })
const app = createClient(socket)
// enrich app with offline capabilities
offlinePlugin(app)
// create a local model (IndexedDB-backed), indexing the listed fields
const post = app.createOfflineModel('post', ['title'])createOfflineModel(modelName, fields) creates a Dexie database named modelName with three stores:
values— the actual records, indexed onuid,__deleted__, and the listedfieldsmetadata— sync timestamps for each recordwhereList— the set ofwherequeries that are actively synchronised
CRUD operations
All writes are optimistic: they update IndexedDB immediately and send the request to the server in the background. If the server call fails, the local change is rolled back.
// create — uid is generated client-side (UUIDv7)
const newPost = await post.create({ title: 'Hello', content: 'World' })
// update
await post.update(newPost.uid, { title: 'Updated title' })
// soft-delete (marked __deleted__, purged on next sync)
await post.remove(newPost.uid)
// direct cache reads (no server call)
const one = await post.findByUID(uid)
const list = await post.findWhere({ title: 'Hello' })Real-time observable
getObservable(where) returns an RxJS Observable that emits the current list whenever the local IndexedDB changes. It also registers where for synchronisation so the perimeter is kept up to date.
import { onUnmounted } from 'vue'
const subscription = post.getObservable({ userId: 42 }).subscribe(posts => {
myList.value = posts
})
onUnmounted(() => subscription.unsubscribe())The observable is powered by Dexie liveQuery and automatically cleans up its sync registration when the Vue/React component scope is disposed (via @vueuse/core tryOnScopeDispose).
Sync behaviour
- On connect: all registered
wherequeries are synchronised against the server. - On disconnect:
app.disconnectedDateis recorded; used ascutoffDateduring the next sync. - Conflict resolution: last-write-wins based on
updated_attimestamps. - Deletions: a record deleted on the client is soft-deleted locally (
deleted_atset) and removed from the server on the next sync; the server deletion is then propagated to all other clients via pub/sub. - Mutual exclusion: sync operations on the same
(model, where)key are serialised server-side to prevent race conditions between concurrent clients.