UNPKG

@lighttable/lighttable

Version:

LightTable SDK avec synchronisation hybride

454 lines (396 loc) 13.6 kB
const axios = require('axios'); const { addRxPlugin, createRxDatabase } = require('rxdb'); import { replicateCouchDB, getFetchWithCouchDBAuthorization } from 'rxdb/plugins/replication-couchdb'; // Correction des imports natifs pour compatibilité universelle let NetInfo = null; let SQLiteAdapter = null; let IndexedDBAdapter = null; let AsyncStorage = null; const isReactNative = typeof navigator !== 'undefined' && navigator.product === 'ReactNative'; if (isReactNative) { try { NetInfo = require('@react-native-community/netinfo'); SQLiteAdapter = require('pouchdb-adapter-react-native-sqlite'); AsyncStorage = require('@react-native-async-storage/async-storage'); } catch (e) { // Optionally log or throw if you want to force install } } else if (typeof window !== 'undefined') { try { IndexedDBAdapter = require('pouchdb-adapter-idb'); } catch (e) {} } const p2pSync = require('./p2p-sync'); // --- POLYFILLS CHECK POUR REACT NATIVE --- if (isReactNative) { // Vérifie la présence des polyfills Node.js nécessaires try { if (!global.Buffer) throw new Error('Missing Buffer polyfill'); if (!global.process) throw new Error('Missing process polyfill'); if (!global.EventEmitter) throw new Error('Missing events polyfill'); if (!global.Stream) throw new Error('Missing stream polyfill'); } catch (e) { throw new Error( '[LightTable] Missing Node.js polyfills for React Native!\n' + 'Add this at the top of your index.js or App.js:\n' + "import { Buffer } from 'buffer';\nglobal.Buffer = Buffer;\nglobal.process = require('process');\nglobal.EventEmitter = require('events');\nglobal.Stream = require('stream-browserify');\n" + '\nError: ' + e.message ); } } class LightTable { constructor({ baseUrl, store, token = null, allowOffline = false, allowP2P = false }) { this.baseUrl = (baseUrl || 'https://mfumu.labelflow.co/sdk/v1/lighttable').replace(/\/$/, ''); if (!store) throw new Error("`store` is required"); this.store = store; this.allowOffline = allowOffline; this.allowP2P = allowP2P; this.mode = 'online'; this.db = null; this.syncQueue = new Map(); // RxDB config for offline if (this.allowOffline) { if (isReactNative) { if (SQLiteAdapter) addRxPlugin(SQLiteAdapter); } else if (IndexedDBAdapter) { addRxPlugin(IndexedDBAdapter); } } // Token storage if (!token && isReactNative && AsyncStorage) { token = AsyncStorage.getItem('lighttable_token'); } else if (!token && typeof window !== 'undefined' && window.localStorage) { token = localStorage.getItem('lighttable_token'); } this.token = token; // Network and P2P this._initNetworkMonitoring(); if (this.allowP2P) { this._initP2P(); } } async _initP2P() { try { await p2pSync.startAdvertising(); // Configuration du handler de réception de données P2P p2pSync.onDataReceived = async (data) => { if (data.type === 'sync' && data.collection && data.changes) { await this._handleP2PSync(data.collection, data.changes, data.token); } }; } catch (error) { console.error('[LightTable] Erreur initialisation P2P:', error); } } async _handleP2PSync(collectionName, changes, token) { if (!this.db) return; // Vérification du token JWT if (!this._isValidToken(token)) { console.warn('[LightTable] Token JWT P2P invalide, données ignorées'); return; } const collection = await this._getOfflineCollection(collectionName); if (!collection) return; try { for (const change of changes) { await collection.atomicUpsert({ ...change, __lastModified: Date.now(), __syncStatus: 'synced', __syncSource: 'p2p' }); } } catch (error) { console.error('[LightTable] Erreur sync P2P:', error); } } _isValidToken(token) { // Vérification basique du JWT (décodage + store) if (!token || typeof token !== 'string') return false; try { const payload = JSON.parse(atob(token.split('.')[1])); return payload && payload.store === this.store; } catch (e) { return false; } } async _initNetworkMonitoring() { if (NetInfo) { NetInfo.addEventListener(state => { if (state.isConnected) { this._switchMode('online'); } else if (this.allowOffline) { this._switchMode('offline'); } }); } } async _initOfflineDB() { if (!this.db && this.allowOffline) { this.db = await createRxDatabase({ name: 'lighttable_' + this.store, adapter: isReactNative ? 'react-native-sqlite' : 'idb', multiInstance: false, eventReduce: true }); } } async _switchMode(newMode) { if (this.mode === newMode) return; console.log(`[LightTable] Mode: ${this.mode} -> ${newMode}`); this.mode = newMode; if (newMode === 'online' && this.syncQueue.size > 0) { await this._processSyncQueue(); } } setToken(token) { this.token = token; if (isReactNative && AsyncStorage) { AsyncStorage.setItem('lighttable_token', token); } else if (typeof window !== 'undefined' && window.localStorage) { localStorage.setItem('lighttable_token', token); } } _headers() { return { 'Content-Type': 'application/json', 'x-store-id': this.store, ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}) }; } async authOtp({ type, phone, email }) { if (!type || (type === 'phone' && !phone) || (type === 'email' && !email)) { throw new Error("`type` et `phone` ou `email` sont requis"); } const res = await axios.post(`${this.baseUrl}/auth-otp`, { type, phone, email }, { headers: this._headers() }); return res.data; } async getSignedUrl(key) { const res = await axios.post(`${this.baseUrl}/signed-url`, { key }, { headers: this._headers() }); return res.data.url; } async verifyOtp({ code, phone, email }) { if (!code || (!phone && !email)) { throw new Error("`code` et `phone` ou `email` sont requis"); } const res = await axios.post(`${this.baseUrl}/auth-otp/verify`, { code, phone, email }, { headers: this._headers() }); if (res.data?.token) { this.setToken(res.data.token); } return res.data; } async getMe() { const res = await axios.get(`${this.baseUrl}/me`, { headers: this._headers() }); return res.data; } async updateMe(payload) { const res = await axios.put(`${this.baseUrl}/me`, payload, { headers: this._headers() }); return res.data; } collection(name) { if (!name) throw new Error("Nom de collection requis"); class QueryBuilder { constructor(parent, operation, initialArgs = []) { this.parent = parent; this.operation = operation; this.args = initialArgs.length ? initialArgs : [{}]; this.isOffline = parent.mode !== 'online'; } sort(sortObj) { this.args[0].sort = sortObj; return this; } limit(limitVal) { this.args[0].limit = limitVal; return this; } skip(skipVal) { this.args[0].skip = skipVal; return this; } filter(filterObj) { this.args[0].filter = filterObj; return this; } select(selectVal) { this.args[0].select = selectVal; return this; } populate(populateVal) { this.args[0].populate = populateVal; return this; } lean() { this.args[0].lean = true; return this; } async exec() { if (this.isOffline && this.parent.allowOffline) { return await this._execOffline(); } return await this._execOnline(); } async _execOffline() { await this.parent._initOfflineDB(); const collection = await this.parent._getOfflineCollection(name); const query = collection.find(this.args[0].filter || {}); if (this.args[0].sort) query.sort(this.args[0].sort); if (this.args[0].limit) query.limit(this.args[0].limit); if (this.args[0].skip) query.skip(this.args[0].skip); const results = await query.exec(); return results.map(doc => doc.toJSON()); } async _execOnline() { let finalArgs = this.args; if (["find", "findOne"].includes(this.operation) && this.args.length === 1) { let { filter = {}, limit, skip, page, sort, ...rest } = this.args[0]; if (page !== undefined && limit !== undefined) { skip = (page - 1) * limit; } let query = filter; let options = { ...rest }; if (limit !== undefined) options.limit = limit; if (skip !== undefined) options.skip = skip; if (sort !== undefined) options.sort = sort; finalArgs = [query, options]; } const res = await axios.post(`${this.parent.baseUrl}`, { operation: this.operation, args: finalArgs, collection: name }, { headers: this.parent._headers() }); return res.data.data; } then(res, rej) { return this.exec().then(res, rej); } catch(rej) { return this.exec().catch(rej); } } const operations = [ 'find', 'findOne', 'count', 'countDocuments', 'findOneAndUpdate', 'findById', 'findByIdAndUpdate', 'deleteOne', 'deleteMany', 'updateOne', 'updateMany', 'save', 'insertMany', 'aggregate', 'distinct', 'create' ]; const model = {}; for (const op of operations) { if (["find", "findOne"].includes(op)) { model[op] = (args = {}) => new QueryBuilder(this, op, [args]); } else { model[op] = async (...args) => { if (this.mode !== 'online' && this.allowOffline) { // Stockage offline et mise en file d'attente await this._addToSyncQueue(name, op, args); return this._execOfflineOperation(name, op, args); } return this._execOnlineOperation(name, op, args); }; } } return model; } async _getOfflineCollection(name) { if (!this.db) await this._initOfflineDB(); if (!this.db[name]) { await this.db.addCollections({ [name]: { schema: { version: 0, type: 'object', properties: { _id: { type: 'string', primary: true }, _rev: { type: 'string' }, __store: { type: 'string' }, __lightTable: { type: 'boolean' }, __lastModified: { type: 'number' }, __syncStatus: { type: 'string' } }, required: ['_id'] } } }); } return this.db[name]; } async _addToSyncQueue(collection, operation, args) { if (!this.syncQueue.has(collection)) { this.syncQueue.set(collection, []); } this.syncQueue.get(collection).push({ operation, args, timestamp: Date.now() }); } async _processSyncQueue() { for (const [collection, operations] of this.syncQueue.entries()) { while (operations.length > 0) { const op = operations.shift(); try { await this._execOnlineOperation(collection, op.operation, op.args); } catch (error) { console.error(`[LightTable] Erreur de synchronisation:`, error); operations.unshift(op); // Remise en file d'attente break; } } } } async _execOfflineOperation(collection, operation, args) { const offlineCollection = await this._getOfflineCollection(collection); const [doc] = args; const now = Date.now(); const result = await (() => { switch (operation) { case 'create': case 'save': return offlineCollection.insert({ ...doc, __lastModified: now, __syncStatus: 'pending' }); case 'updateOne': case 'findOneAndUpdate': return offlineCollection.atomicUpdate(args[0]._id, oldDoc => ({ ...oldDoc, ...args[1].$set, __lastModified: now, __syncStatus: 'pending' })); case 'deleteOne': return offlineCollection.remove(args[0]); default: throw new Error(`Opération ${operation} non supportée en mode offline`); } })(); // Si P2P activé, propager les changements if (this.allowP2P && result) { await p2pSync.sendData({ type: 'sync', collection, changes: [result], token: this.token }); } return result; } async _execOnlineOperation(collection, operation, args) { // Ajout automatique de __lastModified pour les opérations d'écriture const now = Date.now(); if (["create", "save"].includes(operation) && args[0] && typeof args[0] === 'object') { args[0].__lastModified = now; } if (["updateOne", "findOneAndUpdate"].includes(operation) && args[1] && args[1].$set) { args[1].$set.__lastModified = now; } const res = await axios.post(`${this.baseUrl}`, { operation, args, collection }, { headers: this._headers() }); return res.data.data; } async cleanup() { if (this.db) { await this.db.cleanup(); await this.db.remove(); } if (this.allowP2P) { await p2pSync.cleanup(); } } } export default LightTable; // Pour compatibilité CommonJS module.exports = LightTable;