sveltekit-sync
Version:
Local-first sync engine for SvelteKit
670 lines (669 loc) • 23.5 kB
JavaScript
import { RealtimeClient } from '../realtime/client.js';
import {} from '../realtime/types.js';
import { QueryBuilder } from './query/builder.js';
import { createFieldsProxy } from './query/field-proxy.js';
import { PresenceStore } from './presence.svelte.js';
import { SyncChannel } from './channel.svelte.js';
// MULTI-TAB SYNC COORDINATOR
class MultiTabCoordinator {
channel;
listeners = new Map();
constructor(channelName) {
this.channel = new BroadcastChannel(channelName);
this.channel.onmessage = (event) => {
const { type, payload } = event.data;
const handlers = this.listeners.get(type);
if (handlers) {
handlers.forEach(handler => handler(payload));
}
};
}
broadcast(type, payload) {
this.channel.postMessage({ type, payload });
}
on(type, handler) {
if (!this.listeners.has(type)) {
this.listeners.set(type, new Set());
}
this.listeners.get(type).add(handler);
// Return unsubscribe function
return () => {
const handlers = this.listeners.get(type);
if (handlers) {
handlers.delete(handler);
}
};
}
close() {
this.channel.close();
}
}
// SYNC ENGINE CORE
export class SyncEngine {
config;
syncTimer = null;
isSyncing = $state(false);
syncStatus = $state('idle');
pendingOps = $state([]);
conflicts = $state([]);
lastSync = $state(0);
clientId = $state('');
isInitialized = $state(false);
multiTab;
collections = new Map();
realtimeClient = null;
realtimeStatus = $state('disconnected');
constructor(config) {
this.config = {
syncInterval: 30000,
batchSize: 50,
conflictResolution: 'last-write-wins',
retryAttempts: 3,
retryDelay: 1000,
onSync: () => { },
onConflict: () => { },
onError: () => { },
...config
};
this.multiTab = new MultiTabCoordinator('sveltekit-sync');
this.setupMultiTabSync();
// Initialise realtime
if (typeof window !== 'undefined') {
const realtimeConfig = {
enabled: true,
endpoint: '/api/sync/realtime',
tables: [],
reconnectInterval: 1000,
maxReconnectInterval: 30000,
maxReconnectAttempts: 5,
heartbeatTimeout: 45000,
...config.realtime,
onStatusChange: (status) => {
this.realtimeStatus = status;
this.handleRealtimeStatusChange(status);
},
onOperations: (operations) => {
this.handleRealtimeOperations(operations);
},
onError: (error) => {
console.error('Realtime error', error);
this.config.onError(error);
}
};
this.realtimeClient = new RealtimeClient(realtimeConfig);
}
}
setupMultiTabSync() {
// Listen for changes from other tabs
this.multiTab.on('data-changed', async ({ table, operation, data }) => {
const collection = this.collections.get(table);
if (collection) {
await collection.reload();
}
});
// Listen for sync events from other tabs
this.multiTab.on('sync-complete', async () => {
// Reload all collections when another tab syncs
for (const collection of this.collections.values()) {
await collection.reload();
}
});
}
handleRealtimeStatusChange(status) {
if (status === 'fallback') {
// SSE failed, ensure polling is active
if (this.config.syncInterval > 0 && !this.syncTimer)
this.startAutoSync();
}
else if (status === 'connected') {
// TODO SSE connected, we can optionally reduce polling frequency
// but keep it as fallback
}
}
async handleRealtimeOperations(operations) {
// TODO create apply
for (const op of operations) {
if (op.clientId === this.clientId)
continue;
try {
switch (op.operation) {
case 'insert':
await this.config.local.adapter.insert(op.table, op.data);
break;
case 'update': {
const existing = await this.config.local.adapter.findOne(op.table, op.data.id);
const merged = existing ? { ...existing, ...op.data } : op.data;
await this.config.local.adapter.update(op.table, op.data.id, merged);
break;
}
case 'delete':
await this.config.local.adapter.delete(op.table, op.data.id);
break;
}
//Update collection
const collection = this.collections.get(op.table);
if (collection)
await collection.reload();
}
catch (error) {
console.error('Failed to apply realtime operation:', error);
}
}
//update lastSync timestamp
if (operations.length > 0) {
const maxTimestamp = Math.max(...operations.map((op) => op.timestamp));
if (maxTimestamp > this.lastSync) {
await this.config.local.adapter.setLastSync(maxTimestamp);
this.lastSync = maxTimestamp;
}
}
this.multiTab.broadcast('sync-complete', {});
}
async init() {
if (this.isInitialized) {
console.warn('SyncEngine already initialized');
return;
}
try {
this.clientId = await this.config.local.adapter.getClientId();
this.lastSync = await this.config.local.adapter.getLastSync();
this.pendingOps = await this.config.local.adapter.getQueue();
// Check if this is the first time initializing
const hasInitialData = await this.config.local.adapter.isInitialized();
if (!hasInitialData) {
// First initialization - pull initial data
await this.pullInitialData();
await this.config.local.adapter.setInitialized(true);
}
this.isInitialized = true;
if (this.config.syncInterval > 0) {
this.startAutoSync();
}
// Start realtime connection
if (this.realtimeClient) {
this.realtimeClient.init(this.clientId);
}
}
catch (error) {
console.error('SyncEngine initialization failed:', error);
throw new Error(`Failed to initialize sync engine: ${error}`);
}
}
async pullInitialData() {
try {
// Pull all data from server (lastSync = 0 means "get everything")
const operations = await this.config.remote.pull(0, this.clientId);
// Apply operations to local DB
for (const op of operations) {
try {
switch (op.operation) {
case 'insert':
case 'update': {
const existing = await this.config.local.adapter.findOne(op.table, op.data.id);
const merged = existing ? { ...existing, ...op.data } : op.data;
await this.config.local.adapter.update(op.table, op.data.id, merged);
break;
}
case 'delete':
await this.config.local.adapter.delete(op.table, op.data.id);
break;
}
}
catch (error) {
console.error('Failed to apply initial operation:', error);
}
}
// Update last sync timestamp
if (operations.length > 0) {
const maxTimestamp = Math.max(...operations.map((op) => op.timestamp));
await this.config.local.adapter.setLastSync(maxTimestamp);
this.lastSync = maxTimestamp;
}
}
catch (error) {
console.error('Failed to pull initial data:', error);
throw error;
}
}
ensureInitialized() {
if (!this.isInitialized) {
throw new Error('SyncEngine not initialized. Call syncEngine.init() before using collections.');
}
}
async create(table, data) {
this.ensureInitialized();
const id = data.id || crypto.randomUUID();
const record = { ...data, id, _version: 1, _updatedAt: new Date() };
await this.config.local.adapter.insert(table, record);
const operation = {
id: crypto.randomUUID(),
table,
operation: 'insert',
data: record,
timestamp: new Date(),
clientId: this.clientId,
version: 1,
status: 'pending'
};
await this.config.local.adapter.addToQueue(operation);
this.pendingOps.push(operation);
// Notify other tabs
this.multiTab.broadcast('data-changed', { table, operation: 'insert', data: record });
if (this.config.syncInterval === 0) {
this.sync();
}
return record;
}
async update(table, id, data) {
this.ensureInitialized();
const current = await this.config.local.adapter.findOne(table, id);
const version = (current?._version || 0) + 1;
const record = { ...current, ...data, id, _version: version, _updatedAt: new Date() };
await this.config.local.adapter.update(table, id, record);
const operation = {
id: crypto.randomUUID(),
table,
operation: 'update',
data: record,
timestamp: new Date(),
clientId: this.clientId,
version,
status: 'pending'
};
await this.config.local.adapter.addToQueue(operation);
this.pendingOps.push(operation);
// Notify other tabs
this.multiTab.broadcast('data-changed', { table, operation: 'update', data: record });
if (this.config.syncInterval === 0) {
this.sync();
}
return record;
}
async delete(table, id) {
this.ensureInitialized();
await this.config.local.adapter.delete(table, id);
const operation = {
id: crypto.randomUUID(),
table,
operation: 'delete',
data: { id },
timestamp: new Date(),
clientId: this.clientId,
version: 1,
status: 'pending'
};
await this.config.local.adapter.addToQueue(operation);
this.pendingOps.push(operation);
// Notify other tabs
this.multiTab.broadcast('data-changed', { table, operation: 'delete', data: { id } });
if (this.config.syncInterval === 0) {
this.sync();
}
}
async find(table, query) {
this.ensureInitialized();
return this.config.local.adapter.find(table, query);
}
async findOne(table, id) {
this.ensureInitialized();
return this.config.local.adapter.findOne(table, id);
}
async sync(force = false) {
if (this.isSyncing && !force)
return;
this.isSyncing = true;
this.syncStatus = 'syncing';
this.config.onSync('syncing');
try {
await this.push();
await this.pull();
if (this.conflicts.length > 0) {
await this.resolveConflicts();
}
// Notify other tabs that sync completed
this.multiTab.broadcast('sync-complete', {});
this.syncStatus = 'idle';
this.config.onSync('idle');
}
catch (error) {
this.syncStatus = 'error';
this.config.onSync('error');
this.config.onError(error);
throw error;
}
finally {
this.isSyncing = false;
}
}
async push() {
const queue = await this.config.local.adapter.getQueue();
const pending = queue.filter((op) => op.status === 'pending');
if (pending.length === 0)
return;
for (let i = 0; i < pending.length; i += this.config.batchSize) {
const batch = pending.slice(i, i + this.config.batchSize);
try {
const result = await this.config.remote.push(batch);
if (result.synced.length > 0) {
await this.config.local.adapter.removeFromQueue(result.synced);
this.pendingOps = this.pendingOps.filter((op) => !result.synced.includes(op.id));
}
if (result.conflicts.length > 0) {
this.conflicts.push(...result.conflicts);
this.syncStatus = 'conflict';
this.config.onSync('conflict');
result.conflicts.forEach((c) => this.config.onConflict(c));
}
for (const error of result.errors) {
await this.config.local.adapter.updateQueueStatus(error.id, 'error', error.error);
}
}
catch (error) {
console.error('Push error:', error);
throw error;
}
}
}
async pull() {
const lastSync = await this.config.local.adapter.getLastSync();
const operations = await this.config.remote.pull(lastSync, this.clientId);
for (const op of operations) {
if (op.clientId === this.clientId)
continue;
try {
switch (op.operation) {
case 'insert':
await this.config.local.adapter.insert(op.table, op.data);
break;
case 'update': {
const existing = await this.config.local.adapter.findOne(op.table, op.data.id);
const merged = existing ? { ...existing, ...op.data } : op.data;
await this.config.local.adapter.update(op.table, op.data.id, merged);
break;
}
case 'delete':
await this.config.local.adapter.delete(op.table, op.data.id);
break;
}
}
catch (error) {
console.error('Failed to apply remote operation:', error);
}
}
const newLastSync = Math.max(...operations.map((op) => op.timestamp), lastSync);
await this.config.local.adapter.setLastSync(newLastSync);
this.lastSync = newLastSync;
}
async resolveConflicts() {
for (const conflict of this.conflicts) {
let resolved = null;
switch (this.config.conflictResolution) {
case 'client-wins':
resolved = conflict.operation;
break;
case 'server-wins':
resolved = {
...conflict.operation,
data: conflict.serverData
};
break;
case 'last-write-wins':
const serverTime = conflict.serverData._updatedAt || 0;
const clientTime = conflict.clientData._updatedAt || 0;
resolved = serverTime > clientTime
? { ...conflict.operation, data: conflict.serverData }
: conflict.operation;
break;
case 'manual':
if (this.config.remote.resolve) {
resolved = await this.config.remote.resolve(conflict);
}
break;
}
if (resolved) {
await this.config.local.adapter.update(resolved.table, resolved.data.id, resolved.data);
await this.config.local.adapter.removeFromQueue([conflict.operation.id]);
}
}
this.conflicts = [];
}
startAutoSync() {
this.stopAutoSync();
this.syncTimer = window.setInterval(() => {
this.sync();
}, this.config.syncInterval);
}
stopAutoSync() {
if (this.syncTimer !== null) {
clearInterval(this.syncTimer);
this.syncTimer = null;
}
}
get state() {
return {
isSyncing: this.isSyncing,
status: this.syncStatus,
pendingOps: this.pendingOps,
conflicts: this.conflicts,
lastSync: this.lastSync,
hasPendingChanges: this.pendingOps.length > 0,
realtimeStatus: this.realtimeStatus,
isRealtimeConnected: this.realtimeStatus === 'connected',
};
}
collection(tableName) {
if (!this.collections.has(tableName)) {
const collection = new CollectionStore(this, tableName);
this.collections.set(tableName, collection);
}
return this.collections.get(tableName);
}
async forcePush() {
await this.push();
}
async forcePull() {
await this.pull();
}
get realtime() {
return this.realtimeClient;
}
enableRealtime() {
this.realtimeClient?.enable();
}
disableRealtime() {
this.realtimeClient?.disable();
}
/** Force realtime reconnection */
reconnectRealtime() {
this.realtimeClient?.reconnect();
}
/**
* Create a channel for scoped real-time communication
*/
channel(name, options, user) {
if (!this.realtimeClient) {
throw new Error('Realtime client is not initialized');
}
return new SyncChannel(this.realtimeClient, name, user, options);
}
destroy() {
this.stopAutoSync();
this.multiTab.close();
this.realtimeClient?.destroy();
}
}
// COLLECTION STORE
export class CollectionStore {
engine;
tableName;
data = $state([]);
isLoading = $state(false);
error = $state(null);
_initialized = $state(false);
_fields;
presenceStore = null;
constructor(engine, tableName) {
this.engine = engine;
this.tableName = tableName;
this._fields = createFieldsProxy();
}
/**
* Get typed field references for building queries
* Usage: todosStore.$.completed.eq(false)
*/
get $() {
return this._fields;
}
/**
* Alias for $ - get typed field references
* Usage: todosStore.fields.completed.eq(false)
*/
get fields() {
return this._fields;
}
/**
* Create a new query builder for this collection
* Supports multiple query syntaxes
* 1. Callback (full type inference):
* .where(todo => todo.completed === false)
* 2. Object syntax (simple equality):
* .where({ completed: false })
* 3. Object with operators:
* .where({ priority: gte(5) })
* 4. Proxy callback:
* .where(f => f.completed.eq(false))
* 5. Field condition:
* .where(fields.completed.eq(false))
* @returns A new QueryBuilder instance
*/
query() {
return new QueryBuilder(this);
}
/**
* Enable presence/awareness for this collection
*/
presence(config) {
if (!this.presenceStore) {
this.presenceStore = new PresenceStore(this.engine.realtime, this.tableName, config.user, config.custom);
}
return this.presenceStore;
}
get count() {
return this.data.length;
}
get isEmpty() {
return this.data.length === 0;
}
async create(data) {
try {
this.error = null;
const id = data.id || crypto.randomUUID();
const tempRecord = { ...data, id };
this.data.push(tempRecord);
const record = await this.engine.create(this.tableName, tempRecord);
const index = this.data.findIndex(item => item.id === id);
if (index !== -1)
this.data[index] = record;
return record;
}
catch (error) {
this.error = error;
throw error;
}
}
async update(id, data) {
try {
this.error = null;
const index = this.data.findIndex(item => item.id === id);
if (index === -1)
throw new Error(`Record with id ${id} not found`);
const updatedRecord = { ...this.data[index], ...data };
this.data[index] = updatedRecord; // Optimistic update
const record = await this.engine.update(this.tableName, id, data);
this.data[index] = record;
return record;
}
catch (error) {
this.error = error;
throw error;
}
}
async delete(id) {
try {
this.error = null;
const index = this.data.findIndex(item => item.id === id);
if (index === -1)
throw new Error(`Record with id ${id} not found`);
// Optimistic delete
this.data.splice(index, 1);
await this.engine.delete(this.tableName, id);
}
catch (error) {
this.error = error;
throw error;
}
}
async findOne(id) {
try {
this.error = null;
return await this.engine.findOne(this.tableName, id);
}
catch (error) {
this.error = error;
throw error;
}
}
async load(query) {
try {
this.isLoading = true;
this.error = null;
this.data = await this.engine.find(this.tableName, query);
this._initialized = true;
}
catch (error) {
this.error = error;
console.error(`Error loading ${this.tableName}:`, error);
throw error;
}
finally {
this.isLoading = false;
}
}
async reload() {
await this.load();
}
find(predicate) {
return this.data.find(predicate);
}
filter(predicate) {
return this.data.filter(predicate);
}
map(mapper) {
return this.data.map(mapper);
}
sort(compareFn) {
return [...this.data].sort(compareFn);
}
async createMany(items) {
const results = [];
for (const item of items) {
results.push(await this.create(item));
}
return results;
}
async deleteMany(ids) {
for (const id of ids) {
await this.delete(id);
}
}
async updateMany(updates) {
const results = [];
for (const { id, data } of updates) {
results.push(await this.update(id, data));
}
return results;
}
clear() {
this.data = [];
}
}