Skip to content
On this page

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:

  1. Have a uid TEXT PRIMARY KEY column (a UUIDv7 generated client-side)
  2. Share a single metadata table that tracks creation, update, and soft-delete timestamps
sql
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:

js
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:

bash
npm install @jcbuisson/express-x-drizzle drizzle-orm
js
// 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:

MethodDescription
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:

js
{ 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:

js
app.addConnectListener((socket) => {
   app.joinChannel('all', socket)
})

app.service('post').publish(async (context) => ['all'])

Client setup

Install:

bash
npm install @jcbuisson/express-x-client dexie rxjs uuid @vueuse/core
js
import { 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 on uid, __deleted__, and the listed fields
  • metadata — sync timestamps for each record
  • whereList — the set of where queries 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.

js
// 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.

js
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 where queries are synchronised against the server.
  • On disconnect: app.disconnectedDate is recorded; used as cutoffDate during the next sync.
  • Conflict resolution: last-write-wins based on updated_at timestamps.
  • Deletions: a record deleted on the client is soft-deleted locally (deleted_at set) 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.