pulse-rn 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +235 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +20 -0
- package/dist/src/context.d.ts +12 -0
- package/dist/src/context.js +59 -0
- package/dist/src/database.d.ts +3 -0
- package/dist/src/database.js +66 -0
- package/dist/src/operations.d.ts +3 -0
- package/dist/src/operations.js +92 -0
- package/dist/src/sync-engine.d.ts +2 -0
- package/dist/src/sync-engine.js +101 -0
- package/package.json +29 -0
package/README.md
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# pulse-rn
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+
|
|
6
|
+
A lightweight offline-first synchronization engine for React Native/Expo applications. Provides real-time data synchronization between local SQLite database and remote server through WebSocket connections with conflict resolution and delta sync capabilities. It needs a backend server with the django-pulse library to work with. If this is library is well received, I will add more features to it and others backends support.
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- **Offline-first architecture**: Local SQLite database with automatic sync when online
|
|
11
|
+
- **Real-time synchronization**: WebSocket-based bidirectional sync
|
|
12
|
+
- **Conflict resolution**: Version-based conflict handling with server authority
|
|
13
|
+
- **Delta sync**: Only sync changes since last sync to minimize bandwidth
|
|
14
|
+
- **Batch operations**: Efficient batching of local changes
|
|
15
|
+
- **TypeScript support**: Full TypeScript implementation with type safety
|
|
16
|
+
- **React Query integration**: Seamless integration with TanStack Query
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install pulse-rn expo-sqlite expo-crypto @tanstack/react-query
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Configuration
|
|
25
|
+
|
|
26
|
+
### PulseProvider Setup
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
import { PulseProvider } from 'pulse-rn';
|
|
30
|
+
|
|
31
|
+
function App() {
|
|
32
|
+
return (
|
|
33
|
+
<PulseProvider
|
|
34
|
+
config={{
|
|
35
|
+
baseUrl: 'localhost:8000',
|
|
36
|
+
userId: 1,
|
|
37
|
+
tablesConfig: {
|
|
38
|
+
items: ['id', 'name', 'description', 'version', 'is_deleted'],
|
|
39
|
+
users: ['id', 'name', 'email', 'version', 'is_deleted']
|
|
40
|
+
}
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
{/* Your app components */}
|
|
44
|
+
</PulseProvider>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
#### Configuration Parameters
|
|
50
|
+
|
|
51
|
+
| Parameter | Type | Description |
|
|
52
|
+
|-----------|------|-------------|
|
|
53
|
+
| `baseUrl` | string | WebSocket server base URL |
|
|
54
|
+
| `userId` | number | Current user identifier |
|
|
55
|
+
| `tablesConfig` | Record<string, string[]> | Table schema definitions |
|
|
56
|
+
|
|
57
|
+
## API Reference
|
|
58
|
+
|
|
59
|
+
### Hooks
|
|
60
|
+
|
|
61
|
+
#### useSyncTable
|
|
62
|
+
|
|
63
|
+
React hook for querying synchronized table data.
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
import { useSyncTable } from 'pulse-rn';
|
|
67
|
+
|
|
68
|
+
function ItemsList() {
|
|
69
|
+
const { data: items, isLoading, error } = useSyncTable('items');
|
|
70
|
+
|
|
71
|
+
if (isLoading) return <Text>Loading...</Text>;
|
|
72
|
+
if (error) return <Text>Error: {error.message}</Text>;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<FlatList
|
|
76
|
+
data={items}
|
|
77
|
+
renderItem={({ item }) => <Text>{item.name}</Text>}
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
| Parameter | Type | Description |
|
|
84
|
+
|-----------|------|-------------|
|
|
85
|
+
| `tableName` | string | Name of the table to query |
|
|
86
|
+
|
|
87
|
+
**Returns**: Query object with `data`, `isLoading`, `error` properties filtered by `is_deleted = 0`
|
|
88
|
+
|
|
89
|
+
### Functions
|
|
90
|
+
|
|
91
|
+
#### createItem
|
|
92
|
+
|
|
93
|
+
Creates a new record with automatic sync ID generation.
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
import { createItem } from 'pulse-rn';
|
|
97
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
98
|
+
|
|
99
|
+
function CreateItemForm() {
|
|
100
|
+
const queryClient = useQueryClient();
|
|
101
|
+
|
|
102
|
+
const handleCreate = () => {
|
|
103
|
+
createItem('items', {
|
|
104
|
+
name: 'New Item',
|
|
105
|
+
description: 'Item description'
|
|
106
|
+
}, queryClient);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return <Button title="Create Item" onPress={handleCreate} />;
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
| Parameter | Type | Description |
|
|
114
|
+
|-----------|------|-------------|
|
|
115
|
+
| `tableName` | string | Target table name |
|
|
116
|
+
| `data` | object | Record data to insert |
|
|
117
|
+
| `queryClient` | QueryClient | TanStack Query client instance |
|
|
118
|
+
|
|
119
|
+
**Behavior**: Generates UUID `sync_id`, sets `version = 0`, marks `is_local_only = 1`
|
|
120
|
+
|
|
121
|
+
#### updateItem
|
|
122
|
+
|
|
123
|
+
Updates an existing record and queues for synchronization.
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
import { updateItem } from 'pulse-rn';
|
|
127
|
+
|
|
128
|
+
function UpdateItem({ item }) {
|
|
129
|
+
const queryClient = useQueryClient();
|
|
130
|
+
|
|
131
|
+
const handleUpdate = () => {
|
|
132
|
+
updateItem('items', item.sync_id, {
|
|
133
|
+
name: 'Updated Name',
|
|
134
|
+
description: 'Updated description'
|
|
135
|
+
}, queryClient);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return <Button title="Update" onPress={handleUpdate} />;
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
| Parameter | Type | Description |
|
|
143
|
+
|-----------|------|-------------|
|
|
144
|
+
| `tableName` | string | Target table name |
|
|
145
|
+
| `sync_id` | string | Record synchronization identifier |
|
|
146
|
+
| `data` | object | Fields to update |
|
|
147
|
+
| `queryClient` | QueryClient | TanStack Query client instance |
|
|
148
|
+
|
|
149
|
+
**Behavior**: Updates specific fields, marks `is_local_only = 1`, resets `sync_error`
|
|
150
|
+
|
|
151
|
+
#### deleteItem
|
|
152
|
+
|
|
153
|
+
Performs logical deletion of a record.
|
|
154
|
+
|
|
155
|
+
```tsx
|
|
156
|
+
import { deleteItem } from 'pulse-rn';
|
|
157
|
+
|
|
158
|
+
function DeleteItem({ item }) {
|
|
159
|
+
const queryClient = useQueryClient();
|
|
160
|
+
|
|
161
|
+
const handleDelete = () => {
|
|
162
|
+
deleteItem('items', item.sync_id, queryClient);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return <Button title="Delete" onPress={handleDelete} />;
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
| Parameter | Type | Description |
|
|
170
|
+
|-----------|------|-------------|
|
|
171
|
+
| `tableName` | string | Target table name |
|
|
172
|
+
| `sync_id` | string | Record synchronization identifier |
|
|
173
|
+
| `queryClient` | QueryClient | TanStack Query client instance |
|
|
174
|
+
|
|
175
|
+
**Behavior**: Sets `is_deleted = 1` (logical deletion)
|
|
176
|
+
|
|
177
|
+
## Synchronization Logic
|
|
178
|
+
|
|
179
|
+
### Batching System
|
|
180
|
+
|
|
181
|
+
Local changes are batched and sent to the server every 2 seconds to optimize network usage:
|
|
182
|
+
|
|
183
|
+
- Accumulates create/update/delete operations
|
|
184
|
+
- Sends batches of up to 50 records per request
|
|
185
|
+
- Automatic retry on connection failure
|
|
186
|
+
|
|
187
|
+
### Delta Sync
|
|
188
|
+
|
|
189
|
+
When connecting to the server, pulse-rn requests only changes since the last synchronization:
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
{
|
|
193
|
+
type: 'SYNC_REQUEST_DELTA',
|
|
194
|
+
table: 'items',
|
|
195
|
+
last_version: 42
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
- Tracks maximum local version per table
|
|
200
|
+
- Only requests records with version > max_local_version
|
|
201
|
+
- Minimizes bandwidth usage
|
|
202
|
+
|
|
203
|
+
### Conflict Resolution
|
|
204
|
+
|
|
205
|
+
Version-based conflict resolution with server authority:
|
|
206
|
+
|
|
207
|
+
1. Client operations increment version numbers
|
|
208
|
+
2. Server validates and applies changes with final authority
|
|
209
|
+
3. Conflicts resolved by server version precedence
|
|
210
|
+
4. Failed operations marked with `sync_error` field
|
|
211
|
+
|
|
212
|
+
### Connection Management
|
|
213
|
+
|
|
214
|
+
- Automatic reconnection with 3-second backoff
|
|
215
|
+
- WebSocket connection state management
|
|
216
|
+
- Graceful handling of network interruptions
|
|
217
|
+
|
|
218
|
+
## WebSocket Protocol
|
|
219
|
+
|
|
220
|
+
### Message Types
|
|
221
|
+
|
|
222
|
+
| Type | Description |
|
|
223
|
+
|-------|-------------|
|
|
224
|
+
| `SYNC_BATCH_UPLOAD` | Batch of local changes |
|
|
225
|
+
| `SYNC_REQUEST_DELTA` | Request changes since version |
|
|
226
|
+
| `BATCH_ACK` | Batch processing acknowledgment |
|
|
227
|
+
| `SYNC_ACK_INDIVIDUAL` | Individual operation result |
|
|
228
|
+
| `SYNC_UPDATE` | Real-time data updates |
|
|
229
|
+
|
|
230
|
+
## Error Handling
|
|
231
|
+
|
|
232
|
+
- Network errors trigger automatic reconnection
|
|
233
|
+
- Failed operations stored with error messages
|
|
234
|
+
- Sync queue preserved across app restarts
|
|
235
|
+
- Graceful degradation when offline
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./src/database"), exports);
|
|
18
|
+
__exportStar(require("./src/sync-engine"), exports);
|
|
19
|
+
__exportStar(require("./src/operations"), exports);
|
|
20
|
+
__exportStar(require("./src/context"), exports);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
interface PulseConfig {
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
userId: number;
|
|
5
|
+
tablesConfig: Record<string, string[]>;
|
|
6
|
+
}
|
|
7
|
+
export declare const PulseProvider: ({ config, children }: {
|
|
8
|
+
config: PulseConfig;
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
}) => React.JSX.Element;
|
|
11
|
+
export declare const usePulseConfig: () => PulseConfig;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.usePulseConfig = exports.PulseProvider = void 0;
|
|
37
|
+
const react_1 = __importStar(require("react"));
|
|
38
|
+
const database_1 = require("./database");
|
|
39
|
+
const sync_engine_1 = require("./sync-engine");
|
|
40
|
+
const PulseContext = (0, react_1.createContext)(null);
|
|
41
|
+
const PulseProvider = ({ config, children }) => {
|
|
42
|
+
(0, react_1.useEffect)(() => {
|
|
43
|
+
(0, database_1.initializeDB)(config.tablesConfig);
|
|
44
|
+
}, []);
|
|
45
|
+
const tableNames = Object.keys(config.tablesConfig);
|
|
46
|
+
(0, sync_engine_1.usePulseSync)(config.userId, config.baseUrl, tableNames);
|
|
47
|
+
return (<PulseContext.Provider value={config}>
|
|
48
|
+
{children}
|
|
49
|
+
</PulseContext.Provider>);
|
|
50
|
+
};
|
|
51
|
+
exports.PulseProvider = PulseProvider;
|
|
52
|
+
const usePulseConfig = () => {
|
|
53
|
+
const context = (0, react_1.useContext)(PulseContext);
|
|
54
|
+
if (!context) {
|
|
55
|
+
throw new Error("usePulseConfig must be used within a PulseProvider");
|
|
56
|
+
}
|
|
57
|
+
return context;
|
|
58
|
+
};
|
|
59
|
+
exports.usePulseConfig = usePulseConfig;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.initializeDB = exports.db = void 0;
|
|
37
|
+
const SQLite = __importStar(require("expo-sqlite"));
|
|
38
|
+
exports.db = SQLite.openDatabaseSync('djangopulse_v2.db');
|
|
39
|
+
const initializeDB = (schema) => {
|
|
40
|
+
Object.entries(schema).forEach(([tableName, columns]) => {
|
|
41
|
+
const baseFields = ['user_id'];
|
|
42
|
+
const allExpectedColumns = [...baseFields, ...columns];
|
|
43
|
+
exports.db.execSync(`
|
|
44
|
+
CREATE TABLE IF NOT EXISTS ${tableName} (
|
|
45
|
+
sync_id TEXT PRIMARY KEY NOT NULL,
|
|
46
|
+
version INTEGER DEFAULT 0,
|
|
47
|
+
is_local_only INTEGER DEFAULT 0,
|
|
48
|
+
is_deleted INTEGER DEFAULT 0,
|
|
49
|
+
sync_error TEXT DEFAULT NULL
|
|
50
|
+
);
|
|
51
|
+
`);
|
|
52
|
+
const tableInfo = exports.db.getAllSync(`PRAGMA table_info(${tableName})`);
|
|
53
|
+
const existingColumns = tableInfo.map((col) => col.name);
|
|
54
|
+
allExpectedColumns.forEach(colName => {
|
|
55
|
+
if (!existingColumns.includes(colName)) {
|
|
56
|
+
try {
|
|
57
|
+
exports.db.execSync(`ALTER TABLE ${tableName} ADD COLUMN ${colName} TEXT;`);
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
console.error(`Error migrating ${colName}:`, e);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
exports.initializeDB = initializeDB;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare const useSyncTable: (tableName: string) => import("@tanstack/react-query").UseQueryResult<any[], Error>;
|
|
2
|
+
export declare const createItem: (tableName: string, data: any, queryClient: any) => void;
|
|
3
|
+
export declare const updateItem: (tableName: string, sync_id: string, data: any, queryClient: any) => void;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.updateItem = exports.createItem = exports.useSyncTable = void 0;
|
|
37
|
+
const react_query_1 = require("@tanstack/react-query");
|
|
38
|
+
const Crypto = __importStar(require("expo-crypto"));
|
|
39
|
+
const sync_engine_1 = require("./sync-engine");
|
|
40
|
+
const database_1 = require("./database");
|
|
41
|
+
let syncBuffer = {};
|
|
42
|
+
let syncTimeout = null;
|
|
43
|
+
const sendBuffer = (tableName, socket) => {
|
|
44
|
+
const payloads = syncBuffer[tableName];
|
|
45
|
+
if (!payloads || payloads.length === 0)
|
|
46
|
+
return;
|
|
47
|
+
socket.send(JSON.stringify({ type: 'SYNC_BATCH_UPLOAD', table: tableName, payloads }));
|
|
48
|
+
syncBuffer[tableName] = [];
|
|
49
|
+
};
|
|
50
|
+
const useSyncTable = (tableName) => {
|
|
51
|
+
return (0, react_query_1.useQuery)({
|
|
52
|
+
queryKey: [tableName],
|
|
53
|
+
queryFn: async () => database_1.db.getAllSync(`SELECT * FROM ${tableName} WHERE is_deleted = 0`),
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
exports.useSyncTable = useSyncTable;
|
|
57
|
+
const createItem = (tableName, data, queryClient) => {
|
|
58
|
+
const sync_id = Crypto.randomUUID();
|
|
59
|
+
const keys = Object.keys(data);
|
|
60
|
+
const columns = ['sync_id', 'version', 'is_local_only', 'is_deleted', ...keys].join(', ');
|
|
61
|
+
const placeholders = new Array(keys.length + 4).fill('?').join(', ');
|
|
62
|
+
const values = [sync_id, 0, 1, 0, ...Object.values(data)];
|
|
63
|
+
database_1.db.runSync(`INSERT INTO ${tableName} (${columns}) VALUES (${placeholders})`, values);
|
|
64
|
+
queryClient.invalidateQueries({ queryKey: [tableName] });
|
|
65
|
+
const socket = (0, sync_engine_1.getConnectedSocket)();
|
|
66
|
+
if (socket) {
|
|
67
|
+
if (!syncBuffer[tableName])
|
|
68
|
+
syncBuffer[tableName] = [];
|
|
69
|
+
syncBuffer[tableName].push({ sync_id, ...data, is_local_only: 1, is_deleted: 0 });
|
|
70
|
+
if (syncTimeout)
|
|
71
|
+
clearTimeout(syncTimeout);
|
|
72
|
+
syncTimeout = setTimeout(() => sendBuffer(tableName, socket), 2000);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
exports.createItem = createItem;
|
|
76
|
+
const updateItem = (tableName, sync_id, data, queryClient) => {
|
|
77
|
+
const keys = Object.keys(data);
|
|
78
|
+
const setClause = keys.map(key => `${key} = ?`).join(', ');
|
|
79
|
+
const values = [...Object.values(data), sync_id];
|
|
80
|
+
database_1.db.runSync(`UPDATE ${tableName} SET ${setClause}, is_local_only = 1, sync_error = NULL WHERE sync_id = ?`, values);
|
|
81
|
+
queryClient.invalidateQueries({ queryKey: [tableName] });
|
|
82
|
+
const socket = (0, sync_engine_1.getConnectedSocket)();
|
|
83
|
+
if (socket) {
|
|
84
|
+
if (!syncBuffer[tableName])
|
|
85
|
+
syncBuffer[tableName] = [];
|
|
86
|
+
syncBuffer[tableName].push({ sync_id, ...data, is_local_only: 1 });
|
|
87
|
+
if (syncTimeout)
|
|
88
|
+
clearTimeout(syncTimeout);
|
|
89
|
+
syncTimeout = setTimeout(() => sendBuffer(tableName, socket), 2000);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
exports.updateItem = updateItem;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.usePulseSync = exports.getConnectedSocket = void 0;
|
|
4
|
+
const react_1 = require("react");
|
|
5
|
+
const react_query_1 = require("@tanstack/react-query");
|
|
6
|
+
const database_1 = require("./database");
|
|
7
|
+
const CHUNK_SIZE = 50;
|
|
8
|
+
let activeSocket = null;
|
|
9
|
+
const getConnectedSocket = () => {
|
|
10
|
+
if (activeSocket && activeSocket.readyState === WebSocket.OPEN)
|
|
11
|
+
return activeSocket;
|
|
12
|
+
return null;
|
|
13
|
+
};
|
|
14
|
+
exports.getConnectedSocket = getConnectedSocket;
|
|
15
|
+
const handleGenericIncomingData = async (tableName, payload, queryClient) => {
|
|
16
|
+
const { sync_id, version, data } = payload;
|
|
17
|
+
const { id, ...cleanData } = data;
|
|
18
|
+
try {
|
|
19
|
+
const tableInfo = database_1.db.getAllSync(`PRAGMA table_info(${tableName})`);
|
|
20
|
+
const existingColumns = tableInfo.map((col) => col.name);
|
|
21
|
+
const finalData = {};
|
|
22
|
+
Object.keys(cleanData).forEach(key => {
|
|
23
|
+
if (existingColumns.includes(key))
|
|
24
|
+
finalData[key] = cleanData[key];
|
|
25
|
+
});
|
|
26
|
+
if (cleanData.is_deleted === 1 || cleanData.is_deleted === true) {
|
|
27
|
+
database_1.db.runSync(`UPDATE ${tableName} SET is_deleted = 1, version = ? WHERE sync_id = ?`, [version, sync_id]);
|
|
28
|
+
queryClient.invalidateQueries({ queryKey: [tableName] });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const columns = Object.keys(finalData);
|
|
32
|
+
if (columns.length === 0)
|
|
33
|
+
return;
|
|
34
|
+
const placeholders = columns.map(() => '?').join(', ');
|
|
35
|
+
const values = columns.map(col => finalData[col]);
|
|
36
|
+
database_1.db.runSync(`INSERT OR REPLACE INTO ${tableName} (sync_id, version, is_local_only, sync_error, ${columns.join(', ')})
|
|
37
|
+
VALUES (?, ?, 0, NULL, ${placeholders})`, [sync_id, version, ...values]);
|
|
38
|
+
queryClient.invalidateQueries({ queryKey: [tableName] });
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
console.error("SQLite Sync Error:", error);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const usePulseSync = (userId, baseUrl, tables) => {
|
|
45
|
+
const queryClient = (0, react_query_1.useQueryClient)();
|
|
46
|
+
(0, react_1.useEffect)(() => {
|
|
47
|
+
let ws;
|
|
48
|
+
let reconnectTimeout;
|
|
49
|
+
const connect = () => {
|
|
50
|
+
ws = new WebSocket(`ws://${baseUrl}/ws/sync/`);
|
|
51
|
+
activeSocket = ws;
|
|
52
|
+
ws.onopen = () => {
|
|
53
|
+
tables.forEach((tableName) => {
|
|
54
|
+
const pending = database_1.db.getAllSync(`SELECT * FROM ${tableName} WHERE is_local_only = 1`);
|
|
55
|
+
if (pending.length > 0) {
|
|
56
|
+
for (let i = 0; i < pending.length; i += CHUNK_SIZE) {
|
|
57
|
+
const chunk = pending.slice(i, i + CHUNK_SIZE);
|
|
58
|
+
ws.send(JSON.stringify({ type: 'SYNC_BATCH_UPLOAD', table: tableName, payloads: chunk }));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const res = database_1.db.getFirstSync(`SELECT MAX(version) as max_v FROM ${tableName}`);
|
|
62
|
+
ws.send(JSON.stringify({ type: 'SYNC_REQUEST_DELTA', table: tableName, last_version: res?.max_v || 0 }));
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
ws.onmessage = async (e) => {
|
|
66
|
+
const message = JSON.parse(e.data);
|
|
67
|
+
if (message.type === 'BATCH_ACK') {
|
|
68
|
+
const { table, synced_ids, errors } = message;
|
|
69
|
+
if (synced_ids.length > 0) {
|
|
70
|
+
const placeholders = synced_ids.map(() => '?').join(',');
|
|
71
|
+
database_1.db.runSync(`UPDATE ${table} SET is_local_only = 0, sync_error = NULL WHERE sync_id IN (${placeholders})`, synced_ids);
|
|
72
|
+
}
|
|
73
|
+
if (errors?.length > 0) {
|
|
74
|
+
errors.forEach((err) => {
|
|
75
|
+
database_1.db.runSync(`UPDATE ${table} SET sync_error = ? WHERE sync_id = ?`, [err.msg, err.id]);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
queryClient.invalidateQueries({ queryKey: [table] });
|
|
79
|
+
}
|
|
80
|
+
if (message.type === 'SYNC_ACK_INDIVIDUAL') {
|
|
81
|
+
const { table, sync_id, status, error } = message;
|
|
82
|
+
if (status === 'SUCCESS') {
|
|
83
|
+
database_1.db.runSync(`UPDATE ${table} SET is_local_only = 0, sync_error = NULL WHERE sync_id = ?`, [sync_id]);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
database_1.db.runSync(`UPDATE ${table} SET sync_error = ? WHERE sync_id = ?`, [error, sync_id]);
|
|
87
|
+
}
|
|
88
|
+
queryClient.invalidateQueries({ queryKey: [table] });
|
|
89
|
+
}
|
|
90
|
+
if (message.type === 'SYNC_UPDATE') {
|
|
91
|
+
handleGenericIncomingData(message.data.model.toLowerCase(), message.data, queryClient);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
ws.onclose = () => { reconnectTimeout = setTimeout(connect, 3000); activeSocket = null; };
|
|
95
|
+
ws.onerror = () => ws.close();
|
|
96
|
+
};
|
|
97
|
+
connect();
|
|
98
|
+
return () => { clearTimeout(reconnectTimeout); ws?.close(); activeSocket = null; };
|
|
99
|
+
}, [baseUrl]);
|
|
100
|
+
};
|
|
101
|
+
exports.usePulseSync = usePulseSync;
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pulse-rn",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Powerful offline-first sync engine for React Native. Compatible with Django Pulse and future Node.js pulse-core.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"prepare": "npm run build"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["react-native", "expo", "sync", "offline-first", "sqlite", "pulse"],
|
|
16
|
+
"author": "Jesus M.",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"@tanstack/react-query": "^5.0.0",
|
|
20
|
+
"expo-crypto": "*",
|
|
21
|
+
"expo-sqlite": "*",
|
|
22
|
+
"react": "*"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/react": "^18.0.0",
|
|
26
|
+
"@types/react-native": "^0.72.8",
|
|
27
|
+
"typescript": "^5.0.0"
|
|
28
|
+
}
|
|
29
|
+
}
|