sveltekit-sync
Version:
Local-first sync engine for SvelteKit
213 lines (212 loc) • 7.83 kB
JavaScript
import { eq, and, gt, sql, inArray } from 'drizzle-orm';
import { pgTable, text, boolean, timestamp, integer, jsonb, uuid } from 'drizzle-orm/pg-core';
// Sync metadata columns that should be added to all synced tables
export const syncMetadata = {
_version: integer('_version').notNull().default(1),
_updatedAt: timestamp('_updated_at').notNull().defaultNow(),
_clientId: text('_client_id'),
_isDeleted: boolean('_is_deleted').default(false)
};
// Sync log table - tracks all changes for efficient delta sync
export const syncLog = pgTable('_sync_log', {
id: uuid('id').primaryKey().defaultRandom(),
tableName: text('table_name').notNull(),
recordId: text('record_id').notNull(),
operation: text('operation').notNull(),
data: jsonb('data'),
timestamp: timestamp('timestamp').notNull().defaultNow(),
clientId: text('client_id'),
userId: text('user_id').notNull()
});
// Client state table - track last sync for each client
export const clientState = pgTable('_sync_client_state', {
clientId: text('client_id').primaryKey(),
userId: text('user_id').notNull(),
lastSync: timestamp('last_sync').notNull().defaultNow(),
lastActive: timestamp('last_active').notNull().defaultNow()
});
export class DrizzleAdapter {
config;
constructor(config) {
this.config = config;
}
async insert(table, data) {
const schema = this.config.schema[table];
if (!schema)
throw new Error(`Table ${table} not found in schema`);
const transformed = this.config.transformIn?.(table, data) ?? data;
const [result] = await this.config.db
.insert(schema)
.values(transformed)
.returning();
return this.config.transformOut?.(table, result) ?? result;
}
async update(table, id, data, expectedVersion) {
const schema = this.config.schema[table];
const transformed = this.config.transformIn?.(table, data) ?? data;
const [result] = await this.config.db
.update(schema)
.set({
...transformed,
_version: sql `${schema._version} + 1`,
_updatedAt: new Date()
})
.where(and(eq(schema.id, id), eq(schema._version, expectedVersion)))
.returning();
if (!result) {
throw new Error('Version conflict or record not found');
}
return this.config.transformOut?.(table, result) ?? result;
}
async delete(table, id) {
const schema = this.config.schema[table];
await this.config.db
.update(schema)
.set({
_isDeleted: true,
_updatedAt: new Date()
})
.where(eq(schema.id, id));
}
async findOne(table, id) {
const schema = this.config.schema[table];
const [result] = await this.config.db
.select()
.from(schema)
.where(eq(schema.id, id))
.limit(1);
return result ? (this.config.transformOut?.(table, result) ?? result) : null;
}
async find(table, filter) {
const schema = this.config.schema[table];
let query = this.config.db.select().from(schema);
if (filter?.where) {
const conditions = Object.entries(filter.where).map(([key, value]) => eq(schema[key], value));
query = query.where(and(...conditions));
}
if (filter?.limit) {
query = query.limit(filter.limit);
}
const results = await query;
return results.map(r => this.config.transformOut?.(table, r) ?? r);
}
async getChangesSince(table, timestamp, userId, excludeClientId) {
const schema = this.config.schema[table];
const timestampDate = new Date(timestamp);
const conditions = [gt(schema._updatedAt, timestampDate)];
if (excludeClientId) {
conditions.push(sql `${schema._clientId} != ${excludeClientId} OR ${schema._clientId} IS NULL`);
}
if (userId && this.config.getFilter) {
const userFilter = this.config.getFilter(table, userId);
conditions.push(userFilter);
}
const changes = await this.config.db
.select()
.from(schema)
.where(and(...conditions))
.orderBy(schema._updatedAt);
return changes.map(row => ({
id: crypto.randomUUID(),
table,
operation: row._isDeleted ? 'delete' : 'update',
data: this.config.transformOut?.(table, row) ?? row,
timestamp: row._updatedAt.getTime(),
clientId: row._clientId || 'server',
version: row._version,
status: 'synced',
userId: row.userId
}));
}
async applyOperation(op, userId) {
const data = userId ? { ...op.data, userId } : op.data;
switch (op.operation) {
case 'insert':
await this.insert(op.table, {
...data,
_clientId: op.clientId,
_version: 1,
_updatedAt: new Date(op.timestamp)
});
break;
case 'update':
await this.update(op.table, op.data.id, data, op.version - 1);
break;
case 'delete':
await this.delete(op.table, op.data.id);
break;
}
}
async checkConflict(table, id, expectedVersion) {
const schema = this.config.schema[table];
const [current] = await this.config.db
.select({ version: schema._version })
.from(schema)
.where(eq(schema.id, id))
.limit(1);
return current ? current.version !== expectedVersion : false;
}
async batchInsert(table, records) {
const schema = this.config.schema[table];
const transformed = records.map(r => this.config.transformIn?.(table, r) ?? r);
const results = await this.config.db
.insert(schema)
.values(transformed)
.returning();
return results.map(r => this.config.transformOut?.(table, r) ?? r);
}
async batchUpdate(table, updates) {
const results = [];
for (const { id, data } of updates) {
const result = await this.update(table, id, data, data._version - 1);
results.push(result);
}
return results;
}
// SYNC METADATA OPERATIONS
async logSyncOperation(op, userId) {
await this.config.db.insert(syncLog).values({
tableName: op.table,
recordId: op.data.id,
operation: op.operation,
data: op.data,
timestamp: new Date(op.timestamp),
clientId: op.clientId,
userId
});
}
async updateClientState(clientId, userId) {
await this.config.db
.insert(clientState)
.values({
clientId,
userId,
lastSync: new Date(),
lastActive: new Date()
})
.onConflictDoUpdate({
target: clientState.clientId,
set: {
lastSync: new Date(),
lastActive: new Date()
}
});
}
async getClientState(clientId) {
const [result] = await this.config.db
.select()
.from(clientState)
.where(eq(clientState.clientId, clientId))
.limit(1);
return result || null;
}
async transaction(fn) {
return this.config.db.transaction(async (tx) => {
const txAdapter = new DrizzleAdapter({
...this.config,
db: tx
});
return fn(txAdapter);
});
}
}