UNPKG

sveltekit-sync

Version:
213 lines (212 loc) 7.83 kB
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); }); } }