npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

nuxt-synced-pinia-store

v0.1.1

Published

Nuxt 3 module for real-time Pinia store synchronization across clients via SQL database

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

  • 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-store

The module has these peer dependencies (your Nuxt project should already have them):

nuxt ^3.0.0
pinia ^2.0.0

These are bundled with the module and do not need separate installation:

socket.io, socket.io-client, jsondiffpatch, mssql

Configuration

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 automatically

If 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 removed

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

  1. During disconnect, local changes are queued (up to 10 entries).
  2. On reconnect, the client re-joins the room and receives the server's authoritative state.
  3. The client re-diffs its local state against the server state. Only genuine local-only differences are sent back.
  4. 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):

  1. Developer mutates reactive state (store.project.title = 'New').
  2. Pinia $subscribe fires.
  3. After a debounce window (400ms default), the SyncManager diffs the current state against the last snapshot using jsondiffpatch.
  4. The diff is flattened into PathOperation[] -- an explicit list of field-level changes.
  5. Operations are sent to the server via Socket.IO.

Data flow (inbound):

  1. Server receives a delta from another client.
  2. Server assigns a monotonic sequence number (for LWW ordering).
  3. Server broadcasts the delta immediately to all other clients in the room (low latency).
  4. Server queues the delta in the BatchAccumulator for batched DB persistence (high efficiency).
  5. Receiving client applies the PathOperation[] to its reactive state.
  6. 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 dev

Open 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 db2 accessible with user sa, password sa.

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.ts

Test 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 files

Extending and Customizing

Authentication

The module is designed with auth extension points but does not enforce authentication in the current version. To add auth later:

  1. Socket.IO middleware -- The server plugin (syncSocket.ts) has a commented-out io.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'));
  }
});
  1. Room-join authorization -- Add a check inside the SYNC_JOIN handler to verify the user has access to the requested room.

  2. Per-user identity on changes -- The PresenceUser type already has a userId field. Populate it from socket.data.userId after 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:

  1. Create a new adapter class implementing the same interface (getDocumentState, writeBatch, getMaxServerSeq, etc.).
  2. Swap the adapter in the server plugin.
  3. 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.