sequelize-change-tracker
v0.6.0
Published
Wrapper around Sequelize hooks to help distribute changes to specific listeners (subscriptions).
Maintainers
Readme
Sequelize Change Tracker
A reactive extension for Sequelize that enables subscribing to database changes on specific table rows, entire tables, or value-based ranges. Perfect for building real-time applications, live dashboards, virtual scrolling tables, or 3D world simulations.
Features
- Specific Subscriptions — Track changes to individual database rows
- Generic Subscriptions — Monitor all create/update/delete operations on a table
- Range Subscriptions — Track changes within numeric/date ranges (sliding windows)
- Multi-dimensional Ranges — Perfect for 3D viewports (x, y, z coordinates)
- Bucket Subscriptions — Track changes matching discrete values (status = 'pending')
- Cascading Notifications — Automatically notifies parent model subscribers when related models change
- Automatic Subscription via Queries — Subscribe to instances directly through Sequelize query options
- Event-Based Architecture — Built on Node.js EventEmitter for flexible integration
Installation
npm install sequelize-change-trackerQuick Start
import { Sequelize, DataTypes } from 'sequelize';
import SequelizeChangeTracker from 'sequelize-change-tracker';
// Setup Sequelize
const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:' });
const User = sequelize.define('User', {
name: DataTypes.STRING,
email: DataTypes.STRING
});
await sequelize.sync();
// Initialize the change tracker
const tracker = new SequelizeChangeTracker({
models: [User]
});
// Listen for changes
tracker.on('data-changed', (event) => {
console.log(`${event.operation} on ${event.model}:`, event.instance);
console.log('Notify subscriptions:', event.subscriptionIds);
});
// Create a user and track changes
const user = await User.create({ name: 'Alice', email: '[email protected]' });
tracker.addSubscription({
modelName: 'User',
subscriptionId: 'user-alice',
instanceId: user.id
});
// This update will trigger the 'data-changed' event
await user.update({ name: 'Alice Smith' });API Reference
Constructor
new SequelizeChangeTracker({ models })| Parameter | Type | Description |
|-----------|------|-------------|
| models | Array<Model> | Array of Sequelize models to track |
Instance Subscription Methods
addSubscription({ modelName, subscriptionId, instanceId? })
Add a subscription to track changes to specific instances or entire tables.
// Generic subscription — notified on ALL User creates/updates/deletes
tracker.addSubscription({
modelName: 'User',
subscriptionId: 'ws-connection-123'
});
// Specific subscription — notified only when this user changes
tracker.addSubscription({
modelName: 'User',
subscriptionId: 'ws-connection-123',
instanceId: user.id
});removeSubscription({ modelName, subscriptionId, instanceId?, generic? })
Remove a specific subscription.
// Remove specific subscription
tracker.removeSubscription({
modelName: 'User',
subscriptionId: 'ws-connection-123',
instanceId: user.id
});
// Remove generic subscription
tracker.removeSubscription({
modelName: 'User',
subscriptionId: 'ws-connection-123',
generic: true
});Range Subscription Methods (Sliding Windows)
Perfect for virtual scrolling tables, paginated views, or any UI that shows a "window" into data.
setRangeSubscription({ modelName, subscriptionId, windowId, ranges })
Set or update a range subscription. Calling with the same windowId replaces the previous subscription (sliding window behavior).
// Subscribe to orders with amount between 100-500
tracker.setRangeSubscription({
modelName: 'Order',
subscriptionId: 'ws-123',
windowId: 'main-table',
ranges: [
{ field: 'amount', min: 100, max: 500 }
]
});
// User scrolls — slide the window
tracker.setRangeSubscription({
modelName: 'Order',
subscriptionId: 'ws-123',
windowId: 'main-table', // Same windowId = replaces previous
ranges: [
{ field: 'amount', min: 400, max: 800 }
]
});addRangeSubscription({ modelName, subscriptionId, ranges })
Add a static range subscription (accumulates, doesn't replace). Use for persistent filters.
// Always notify about high-value orders
tracker.addRangeSubscription({
modelName: 'Order',
subscriptionId: 'ws-123',
ranges: [
{ field: 'amount', min: 10000, max: Infinity }
]
});removeRangeSubscription({ subscriptionId, windowId })
Remove a specific window subscription.
tracker.removeRangeSubscription({
subscriptionId: 'ws-123',
windowId: 'main-table'
});Multi-dimensional Range Subscriptions (3D Viewports)
For 3D worlds, games, or any multi-dimensional data, use multiple range conditions with AND logic.
// Subscribe to voxels within camera viewport
tracker.setRangeSubscription({
modelName: 'Voxel',
subscriptionId: 'player-1',
windowId: 'camera',
ranges: [
{ field: 'x', min: playerX - 50, max: playerX + 50 },
{ field: 'y', min: playerY - 50, max: playerY + 50 },
{ field: 'z', min: playerZ - 20, max: playerZ + 20 }
]
});
// Player moves — update viewport
function onPlayerMove(x, y, z) {
tracker.setRangeSubscription({
modelName: 'Voxel',
subscriptionId: 'player-1',
windowId: 'camera',
ranges: [
{ field: 'x', min: x - 50, max: x + 50 },
{ field: 'y', min: y - 50, max: y + 50 },
{ field: 'z', min: z - 20, max: z + 20 }
]
});
}Bucket Subscription Methods
For discrete categorical values like status, type, or category.
addBucketSubscription({ modelName, subscriptionId, field, value?, values? })
Subscribe to changes where a field matches specific values.
// Single value
tracker.addBucketSubscription({
modelName: 'Order',
subscriptionId: 'ws-123',
field: 'status',
value: 'pending'
});
// Multiple values (OR logic)
tracker.addBucketSubscription({
modelName: 'Order',
subscriptionId: 'ws-123',
field: 'status',
values: ['pending', 'processing', 'review']
});removeBucketSubscription({ modelName, subscriptionId, field })
Remove a bucket subscription.
tracker.removeBucketSubscription({
modelName: 'Order',
subscriptionId: 'ws-123',
field: 'status'
});Cleanup Methods
removeSubscriptionAllModels(subscriptionId)
Remove all subscriptions for a given subscription ID. Handles instance, generic, range, and bucket subscriptions.
// Client disconnected — clean up everything
tracker.removeSubscriptionAllModels('ws-connection-123');destroy()
Clean up the tracker, removing all hooks and event listeners.
tracker.destroy();Events
data-changed
Emitted when a tracked model changes.
tracker.on('data-changed', (event) => {
// event.operation — 'create' | 'update' | 'delete'
// event.model — Model name (e.g., 'User')
// event.instance — The changed instance data
// event.changedFields — Array of field names that changed
// event.subscriptionIds — Array of subscription IDs to notify
});subscriptions-changed
Emitted when a subscription is added or updated.
tracker.on('subscriptions-changed', (event) => {
// event.subscriptionId — The subscription ID
// event.modelName — Model name
// event.type — 'range-window' | 'range-static' | 'bucket' | undefined
// event.windowId — Window ID (for range-window)
// event.ranges — Range conditions (for range subscriptions)
// event.field — Field name (for bucket subscriptions)
// event.values — Bucket values (for bucket subscriptions)
});Automatic Subscription via Query Options
Subscribe to instances automatically when querying by adding trackChanges to your Sequelize options:
// Subscribe when finding
const user = await User.findOne({
where: { id: userId },
trackChanges: { subscriptionId: 'ws-connection-123' }
});
// Subscribe when creating
const newUser = await User.create(
{ name: 'Bob' },
{ trackChanges: { subscriptionId: 'ws-connection-123' }}
);
// Subscribe to multiple instances
const users = await User.findAll({
trackChanges: { subscriptionId: 'ws-connection-123' }
});Use Case Examples
Virtual Scrolling Table
// As user scrolls through a sorted table
function onScroll(visibleRows) {
const minDate = visibleRows[0].createdAt;
const maxDate = visibleRows[visibleRows.length - 1].createdAt;
tracker.setRangeSubscription({
modelName: 'Order',
subscriptionId: connectionId,
windowId: 'orders-table',
ranges: [
{ field: 'createdAt', min: minDate, max: maxDate }
]
});
}3D Voxel World
const Voxel = sequelize.define('Voxel', {
x: DataTypes.INTEGER,
y: DataTypes.INTEGER,
z: DataTypes.INTEGER,
material: DataTypes.STRING
});
const tracker = new SequelizeChangeTracker({ models: [Voxel] });
// Route changes to players
tracker.on('data-changed', (event) => {
if (event.model === 'Voxel') {
for (const playerId of event.subscriptionIds) {
sendToPlayer(playerId, {
type: 'voxel-update',
operation: event.operation,
voxel: event.instance
});
}
}
});
// When player connects
function onPlayerConnect(playerId, position) {
tracker.setRangeSubscription({
modelName: 'Voxel',
subscriptionId: playerId,
windowId: 'camera',
ranges: [
{ field: 'x', min: position.x - 50, max: position.x + 50 },
{ field: 'y', min: position.y - 50, max: position.y + 50 },
{ field: 'z', min: position.z - 20, max: position.z + 20 }
]
});
}
// When player moves
function onPlayerMove(playerId, position) {
tracker.setRangeSubscription({
modelName: 'Voxel',
subscriptionId: playerId,
windowId: 'camera',
ranges: [
{ field: 'x', min: position.x - 50, max: position.x + 50 },
{ field: 'y', min: position.y - 50, max: position.y + 50 },
{ field: 'z', min: position.z - 20, max: position.z + 20 }
]
});
}
// When player disconnects
function onPlayerDisconnect(playerId) {
tracker.removeSubscriptionAllModels(playerId);
}Real-Time Dashboard
// Main data table with date filter
tracker.setRangeSubscription({
modelName: 'Order',
subscriptionId: connectionId,
windowId: 'main-table',
ranges: [{ field: 'createdAt', min: startDate, max: endDate }]
});
// High-value orders chart (static, always active)
tracker.addRangeSubscription({
modelName: 'Order',
subscriptionId: connectionId,
ranges: [{ field: 'amount', min: 10000, max: Infinity }]
});
// Pending orders alert panel
tracker.addBucketSubscription({
modelName: 'Order',
subscriptionId: connectionId,
field: 'status',
values: ['pending', 'review']
});WebSocket Integration
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
const tracker = new SequelizeChangeTracker({ models: [User, Order, Voxel] });
const clients = new Map();
tracker.on('data-changed', (event) => {
for (const subscriptionId of event.subscriptionIds) {
const client = clients.get(subscriptionId);
if (client?.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({
type: 'data-changed',
model: event.model,
operation: event.operation,
data: event.instance
}));
}
}
});
wss.on('connection', (ws) => {
const connectionId = crypto.randomUUID();
clients.set(connectionId, ws);
ws.on('message', (message) => {
const { action, ...params } = JSON.parse(message);
switch (action) {
case 'subscribe':
tracker.addSubscription({ ...params, subscriptionId: connectionId });
break;
case 'setRange':
tracker.setRangeSubscription({ ...params, subscriptionId: connectionId });
break;
case 'addBucket':
tracker.addBucketSubscription({ ...params, subscriptionId: connectionId });
break;
}
});
ws.on('close', () => {
tracker.removeSubscriptionAllModels(connectionId);
clients.delete(connectionId);
});
});Subscription Types Summary
| Method | Behavior | Use Case |
|--------|----------|----------|
| addSubscription({ instanceId }) | Track specific row | Single record detail view |
| addSubscription({}) | Track all table changes | Table-wide notifications |
| setRangeSubscription | Sliding window (upsert) | Virtual scrolling, 3D viewports |
| addRangeSubscription | Static range (accumulate) | Persistent value filters |
| addBucketSubscription | Discrete values | Status/category filters |
Supported Associations
The tracker understands these Sequelize association types for cascading notifications:
HasOneHasManyBelongsToBelongsToMany
Limitations
bulkUpdateandbulkDestroyoperations do not trigger notifications (Sequelize limitation — these hooks don't receive instance data)
