nuxt-synced-pinia-store
v0.1.1
Published
Nuxt 3 module for real-time Pinia store synchronization across clients via SQL database
Maintainers
Readme
nuxt-synced-pinia-store
Real-time Pinia store synchronization across multiple clients for Nuxt 3 SPA applications, persisted to Microsoft SQL Server.
Multiple users editing the same document see each other's changes within 1--5 seconds. The module handles the entire WebSocket transport, diffing, conflict resolution, and database persistence -- developers just mark which state properties should be synced.
Table of Contents
- Prerequisites
- Installation
- Configuration
- Quick Start
- Developer API
- Store Definition
- Working with Arrays
- Conflict Resolution
- Offline Behavior
- Architecture Overview
- Database Schema
- Configuration Reference
- Running the Playground
- Running Tests
- Project Structure
- Extending and Customizing
Prerequisites
- Node.js 18+
- Nuxt 3 (SPA mode, SSR disabled)
- Pinia 2.x (via
@pinia/nuxt) - Microsoft SQL Server -- a running instance accessible from the Nitro server. The module auto-creates its tables on first startup.
Installation
npm install nuxt-synced-pinia-storeThe module has these peer dependencies (your Nuxt project should already have them):
nuxt ^3.0.0
pinia ^2.0.0These are bundled with the module and do not need separate installation:
socket.io, socket.io-client, jsondiffpatch, mssqlConfiguration
Add the module to your nuxt.config.ts and provide the database connection:
export default defineNuxtConfig({
modules: ['nuxt-synced-pinia-store', '@pinia/nuxt'],
ssr: false, // SPA only -- required
syncedPiniaStore: {
database: {
server: process.env.DB_SERVER || 'localhost\\SQLEXPRESS01',
database: process.env.DB_DATABASE || 'mydb',
user: process.env.DB_USER || 'sa',
password: process.env.DB_PASSWORD || 'sa',
options: {
trustServerCertificate: true,
encrypt: false,
},
},
// Optional tuning -- defaults shown:
sync: {
clientDebounceMs: 400, // wait before sending local changes
serverBatchMs: 1500, // server batches DB writes over this window
presenceTimeoutMs: 15000,
},
},
});On first startup, the module auto-creates two tables (sync_documents and sync_change_log) if they do not already exist. No manual migration step is required.
Security note: Database credentials are only available in server-side runtime config. They are never exposed to the client bundle.
Quick Start
1. Define a synced store
// stores/project.ts
import { defineStore } from 'pinia';
export const useProjectStore = defineStore('project', {
state: () => ({
project: {
title: 'Untitled',
description: '',
settings: { zoom: 1, theme: 'light' },
tasks: [],
},
uiDrawerOpen: false, // local-only, not synced
}),
sync: {
room: () => `project-${useRoute().params.id}`,
include: ['project'], // only 'project' is synced; 'uiDrawerOpen' stays local
},
});2. Use it in a component
<script>
export default {
setup() {
const store = useProjectStore();
return { store };
},
};
</script>
<template>
<input v-model="store.project.title" />
<div v-for="task in store.project.tasks" :key="task._sync_id">
<input v-model="task.title" />
</div>
</template>That's it. Changes to store.project are detected, diffed, sent to the server, broadcast to other clients in the same room, and persisted to SQL Server -- all automatically.
Developer API
The module provides a store sync option and auto-imports several composables. No explicit import statements are needed in your Nuxt app.
Store sync Option
Add a sync property to any Options-style Pinia store to enable synchronization:
export const useDocStore = defineStore('document', {
state: () => ({
doc: { title: '', body: '' },
localDraft: '',
}),
sync: {
room: () => `doc-${useRoute().params.id}`,
include: ['doc'],
},
});sync properties:
| Property | Type | Required | Description |
|---|---|---|---|
| room | string \| (() => string) | Yes | Room key. All clients with the same room share state. Use a getter for dynamic rooms (e.g., route-based). |
| include | string[] | No | List of top-level state keys to sync. If omitted, the entire state is synced. |
| debounceMs | number | No | Override the client-side debounce interval (default: 400ms). |
State keys listed in include are synchronized across clients. All other state keys remain local-only with zero sync overhead.
usePresence
const { users, count } = usePresence(room);Provides a reactive list of users currently connected to a room.
Parameters:
| Parameter | Type | Description |
|---|---|---|
| room | string \| Ref<string> | The room to observe. |
Returns:
| Property | Type | Description |
|---|---|---|
| users | Ref<PresenceUser[]> | Array of connected users. Each has clientId, optional displayName, and joinedAt timestamp. |
| count | ComputedRef<number> | Number of connected users. |
Example:
<script>
export default {
setup() {
const route = useRoute();
const { users, count } = usePresence(`project-${route.params.id}`);
return { users, count };
},
};
</script>
<template>
<span v-for="user in users" :key="user.clientId" class="avatar">
{{ user.displayName?.[0] ?? '?' }}
</span>
<span>{{ count }} online</span>
</template>useSyncStatus
const status = useSyncStatus(store);Returns a reactive ref describing the sync connection state for a store.
Parameters:
| Parameter | Type | Description |
|---|---|---|
| store | Store | A Pinia store instance (must have synced properties). |
Returns a Ref<SyncStatus>:
| Field | Type | Description |
|---|---|---|
| connected | boolean | Whether the WebSocket is connected. |
| syncing | boolean | Whether the store is actively syncing (past initial load). |
| pendingChanges | number | Number of queued offline changes. |
| lastSyncedAt | Date \| null | Timestamp of the last successful sync. |
| error | string \| null | Most recent error message, or null. |
Example:
<script>
export default {
setup() {
const store = useProjectStore();
const status = useSyncStatus(store);
return { status };
},
};
</script>
<template>
<div :class="status.connected ? 'online' : 'offline'">
{{ status.connected ? 'Connected' : 'Disconnected' }}
<span v-if="status.pendingChanges"> -- {{ status.pendingChanges }} pending</span>
</div>
</template>stripSyncIds
const cleanData = stripSyncIds(obj);Returns a deep clone of obj with all internal _sync_id properties removed at every depth. Use this when you need to serialize synced data for export, API calls, or any context where the internal identity field should not appear.
const tasks = stripSyncIds(store.project.tasks);
// tasks is a plain array with no _sync_id on any item
await api.saveTasks(tasks);Store Definition
Add a sync property to any Pinia Options store to enable synchronization. List the top-level state keys you want synced in include; everything else stays local.
export const useDocStore = defineStore('document', {
state: () => ({
doc: {
title: '',
body: '',
metadata: { author: '', version: 1 },
comments: [],
},
localDraft: '',
}),
sync: {
room: () => `doc-${useRoute().params.id}`,
include: ['doc'], // only 'doc' is synced; 'localDraft' is local-only
},
});Unsynced properties have zero overhead. If include is omitted, the entire state is synced.
Working with Arrays
Arrays are first-class citizens. The module tracks array items by identity, not position.
The _sync_id field
Every object element in a synced array is automatically given a _sync_id property (a UUID). This is managed by the sync system and should not be manually modified. It enables:
- Identity-based diffing -- inserting at the beginning doesn't cause every item to appear "changed."
- Per-item conflict resolution -- two users editing different items in the same array don't conflict.
- Delete-wins semantics -- if one user deletes an item while another edits it, the delete takes effect.
Rendering arrays
Always use _sync_id as the v-for key:
<div v-for="task in project.tasks" :key="task._sync_id">
<input v-model="task.title" />
</div>Adding items
Push items normally. The module assigns _sync_id automatically before the next sync cycle:
project.tasks.push({ title: 'New task', done: false });
// _sync_id is assigned automaticallyIf you need the _sync_id immediately (e.g., for a v-for key before the sync cycle fires), assign it yourself:
project.tasks.push({
_sync_id: crypto.randomUUID(),
title: 'New task',
done: false,
});Removing items
Use splice or filter -- the module detects the removal by _sync_id:
const idx = project.tasks.findIndex(t => t._sync_id === targetId);
if (idx !== -1) project.tasks.splice(idx, 1);Nested arrays
_sync_id is assigned recursively. If an array item contains another array of objects, those inner objects also receive _sync_id.
Stripping _sync_id for export
When sending data to an external API or serializing for download, use stripSyncIds():
const clean = stripSyncIds(store.project.tasks);
// clean is a deep clone with all _sync_id properties removedConflict Resolution
The module uses Last Write Wins (LWW) at the field level.
| Scenario | Behavior | |---|---| | Two users edit different fields of the same object | Both changes are preserved. | | Two users edit the same field simultaneously | The write that arrives at the server last wins. The other is silently discarded. | | One user deletes an array item while another edits it | Delete wins. The edit is discarded. | | Two users append items to the same array | Both items are preserved. Server determines final order. |
Strings are never merged. If User A sets title = "Alpha" and User B sets title = "Beta", one replaces the other entirely -- there is no character-level merge producing "AlphaBeta".
Ordering is determined by server-received timestamp (monotonic sequence number), not client clocks.
Offline Behavior
Users are expected to be online the vast majority of the time. The module handles brief disconnections gracefully:
- During disconnect, local changes are queued (up to 10 entries).
- On reconnect, the client re-joins the room and receives the server's authoritative state.
- The client re-diffs its local state against the server state. Only genuine local-only differences are sent back.
- LWW conflict resolution applies naturally -- if the server has a newer value for a path, the client's stale offline value is dropped.
The store continues to function as normal Pinia state during disconnection. The UI never freezes.
Architecture Overview
Browser (Client A) Nitro Server Browser (Client B)
┌───────────────┐ ┌──────────────────┐ ┌───────────────┐
│ Pinia Store │ │ Socket.IO Server │ │ Pinia Store │
│ + SyncManager │◄────►│ + ChangeProcessor│◄────►│ + SyncManager │
│ + DiffEngine │ WS │ + BatchAccumul. │ WS │ + DiffEngine │
│ + Snapshots │ │ + RoomManager │ │ + Snapshots │
└───────────────┘ │ + PresenceHub │ └───────────────┘
│ + DatabaseAdapter│
└────────┬─────────┘
│ SQL
┌────────▼─────────┐
│ SQL Server (MSSQL)│
│ sync_documents │
│ sync_change_log │
└──────────────────┘Data flow (outbound):
- Developer mutates reactive state (
store.project.title = 'New'). - Pinia
$subscribefires. - After a debounce window (400ms default), the
SyncManagerdiffs the current state against the last snapshot usingjsondiffpatch. - The diff is flattened into
PathOperation[]-- an explicit list of field-level changes. - Operations are sent to the server via Socket.IO.
Data flow (inbound):
- Server receives a delta from another client.
- Server assigns a monotonic sequence number (for LWW ordering).
- Server broadcasts the delta immediately to all other clients in the room (low latency).
- Server queues the delta in the
BatchAccumulatorfor batched DB persistence (high efficiency). - Receiving client applies the
PathOperation[]to its reactive state. - Snapshot is updated. No outbound diff is triggered (echo prevention).
Database Schema
The module auto-creates these two tables on first connection. No manual setup is needed.
sync_documents
Stores the latest full state of each synced document.
| Column | Type | Description |
|---|---|---|
| room | NVARCHAR(255) | Room identifier (PK part 1). |
| store_key | NVARCHAR(255) | Store key within the room (PK part 2). |
| state | NVARCHAR(MAX) | Full JSON state blob. |
| server_seq | BIGINT | Sequence number of the latest applied change. |
| updated_at | DATETIME2 | Last update timestamp. |
sync_change_log
Append-only log of individual field changes for auditing and debugging.
| Column | Type | Description |
|---|---|---|
| id | BIGINT IDENTITY | Auto-incrementing primary key. |
| room | NVARCHAR(255) | Room identifier. |
| store_key | NVARCHAR(255) | Store key. |
| path | NVARCHAR(1000) | Dot-notation path of the changed field. |
| op | NVARCHAR(10) | Operation type: set, delete, array_add, array_del. |
| value | NVARCHAR(MAX) | JSON-serialized new value (null for deletes). |
| client_id | NVARCHAR(100) | ID of the client that made the change. |
| server_seq | BIGINT | Global sequence number. |
| server_ts | DATETIME2 | Server timestamp when the change was received. |
Configuration Reference
All options are set under syncedPiniaStore in nuxt.config.ts.
database (required)
| Option | Type | Default | Description |
|---|---|---|---|
| server | string | -- | SQL Server host (e.g., localhost\\SQLEXPRESS01). |
| database | string | -- | Database name. |
| user | string | -- | SQL Server username. |
| password | string | -- | SQL Server password. |
| options.trustServerCertificate | boolean | true | Trust self-signed certs (dev environments). |
| options.encrypt | boolean | false | Use encrypted connection. |
sync (optional)
| Option | Type | Default | Description |
|---|---|---|---|
| clientDebounceMs | number | 400 | Milliseconds to wait after a local mutation before diffing and sending. Higher values reduce network traffic; lower values feel more responsive. |
| serverBatchMs | number | 1500 | Milliseconds the server accumulates changes before writing a batch to SQL. Deltas are still broadcast immediately -- this only affects DB write frequency. |
| presenceTimeoutMs | number | 15000 | How long before a disconnected client is removed from the presence list. |
Running the Playground
The playground/ directory contains a working Nuxt app that demonstrates synced editing.
# From the project root
npm install
npm run devOpen http://localhost:3000 in two browser tabs. Navigate to the same project (e.g., /project/demo-1) in both tabs. Edits in one tab appear in the other within a few seconds.
The playground requires a running SQL Server instance at localhost\SQLEXPRESS01 with database db2 and sa/sa credentials. Adjust playground/nuxt.config.ts if your setup differs.
Running Tests
Prerequisites
- A running SQL Server instance at
localhost\SQLEXPRESS01. - Database
db2accessible with usersa, passwordsa.
Commands
# Run all 119 tests (unit + integration + e2e)
npm test
# Run tests in watch mode
npm run test:watch
# Type-check without emitting
npm run typecheck
# Run specific test files
npx vitest run test/DiffEngine.test.ts
# Run only e2e tests
npx vitest run test/e2e/
# Run the load test (300 clients, 60 rooms, 60 seconds)
npx tsx test/perf/load-test.tsTest structure
| Directory | Contents | Count |
|---|---|---|
| test/*.test.ts | Unit and integration tests for individual components | 14 files |
| test/e2e/*.test.ts | End-to-end tests with real Socket.IO connections and database | 5 files |
| test/e2e-minimal.test.ts | Minimal pipeline validation | 1 file |
| test/perf/ | Load test script | 1 file |
E2E tests use unique room keys per test for isolation. No cleanup step is needed between runs, but the sync_documents and sync_change_log tables will accumulate test data over time. Truncate them manually if desired.
Project Structure
src/
module.ts Nuxt module entry point
types.ts Module options types
runtime/
shared/
types.ts Shared TypeScript interfaces
protocol.ts Socket.IO event name constants
constants.ts Default config values
client/
plugin.ts Nuxt client plugin (registers Pinia plugin)
piniaPlugin.ts Pinia plugin (connects stores to SyncManagers)
syncRegistry.ts Module-level registry bridging composables and plugin
composables/
useSyncedStore.ts Developer API for marking state as synced
usePresence.ts Reactive presence list
useSyncStatus.ts Reactive sync connection status
stripSyncIds.ts Utility to remove _sync_id from data
sync/
SyncManager.ts Central client orchestrator
DiffEngine.ts jsondiffpatch wrapper
SnapshotManager.ts Deep-clone snapshot storage
SyncIdManager.ts Assigns _sync_id to array items
DeltaFlattener.ts Converts diffs to PathOperation[]
DeltaApplier.ts Applies PathOperation[] to reactive state
SocketTransport.ts Socket.IO client wrapper
OfflineQueue.ts Bounded queue for offline changes
server/
plugins/
syncSocket.ts Socket.IO server factory
services/
DatabaseAdapter.ts MSSQL connection, queries, transactions
SequenceGenerator.ts Monotonic counter for global ordering
ChangeProcessor.ts LWW resolution, delete-wins enforcement
BatchAccumulator.ts Time-windowed batching for DB writes
RoomManager.ts Room join/leave, initial state delivery
PresenceHub.ts In-memory presence tracking
applyOpToState.ts Server-side state mutation utility
db/
schema.sql Table definitions (reference)
migrations.ts Auto-creates tables if missing
playground/ Demo Nuxt app
test/ 119 tests across 20 filesExtending and Customizing
Authentication
The module is designed with auth extension points but does not enforce authentication in the current version. To add auth later:
- Socket.IO middleware -- The server plugin (
syncSocket.ts) has a commented-outio.use()middleware hook. Uncomment and implement token validation:
io.use((socket, next) => {
const token = socket.handshake.auth.token;
if (isValid(token)) {
socket.data.userId = decodeUserId(token);
next();
} else {
next(new Error('unauthorized'));
}
});Room-join authorization -- Add a check inside the
SYNC_JOINhandler to verify the user has access to the requested room.Per-user identity on changes -- The
PresenceUsertype already has auserIdfield. Populate it fromsocket.data.userIdafter auth.
Custom initial state loader
By default, initial state is loaded from the SQL database. The RoomManager.join() method can be extended to support custom loaders (e.g., loading from an external API, transforming data, or merging with business logic).
Supporting other databases
The DatabaseAdapter isolates all SQL interaction. To support PostgreSQL, MySQL, or SQLite:
- Create a new adapter class implementing the same interface (
getDocumentState,writeBatch,getMaxServerSeq, etc.). - Swap the adapter in the server plugin.
- Update the migration queries for the target SQL dialect.
Tuning for your workload
| Knob | Effect of increasing | Effect of decreasing |
|---|---|---|
| clientDebounceMs | Fewer network messages, higher perceived latency | More responsive, more traffic |
| serverBatchMs | Fewer DB writes, higher risk of data loss on crash | More frequent writes, more DB load |
| presenceTimeoutMs | Slower disconnect detection | Faster but more sensitive to network jitter |
For ~300 users across ~60 documents with 1--5 second latency tolerance, the defaults (400ms client debounce, 1500ms server batch) are well-suited.
