local-lite 0.0.12 → 0.0.15
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 +5 -3
- package/dist/access/client.d.ts +9 -0
- package/dist/access/client.js +56 -0
- package/dist/access/engine.d.ts +2 -0
- package/dist/access/engine.js +46 -0
- package/dist/access/errors.d.ts +14 -0
- package/dist/access/errors.js +23 -0
- package/dist/access/guard.d.ts +24 -0
- package/dist/access/guard.js +104 -0
- package/dist/access/index.d.ts +5 -0
- package/dist/access/index.js +21 -0
- package/dist/access/react/index.d.ts +32 -0
- package/dist/access/react/index.js +42 -0
- package/dist/access/types.d.ts +26 -0
- package/dist/access/types.js +2 -0
- package/dist/auth/apple.d.ts +91 -0
- package/dist/auth/apple.js +264 -0
- package/dist/auth/github.d.ts +72 -0
- package/dist/auth/github.js +284 -0
- package/dist/auth/index.d.ts +3 -0
- package/dist/auth/index.js +3 -0
- package/dist/auth/microsoft.d.ts +76 -0
- package/dist/auth/microsoft.js +310 -0
- package/dist/backups/encryption.d.ts +18 -0
- package/dist/backups/encryption.js +122 -0
- package/dist/backups/index.d.ts +2 -0
- package/dist/backups/index.js +11 -1
- package/dist/backups/onedrive.d.ts +22 -0
- package/dist/backups/onedrive.js +114 -0
- package/dist/core/client.d.ts +33 -0
- package/dist/core/client.js +46 -0
- package/dist/core/conflict.d.ts +8 -1
- package/dist/core/conflict.js +75 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/maintenance.d.ts +66 -0
- package/dist/core/maintenance.js +157 -0
- package/dist/core/sync-worker.js +6 -3
- package/dist/core/types.d.ts +34 -4
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/react/auth.d.ts +35 -0
- package/dist/react/auth.js +272 -0
- package/dist/react/index.d.ts +12 -0
- package/dist/react/index.js +51 -0
- package/dist/utils/portability.d.ts +73 -0
- package/dist/utils/portability.js +276 -0
- package/package.json +21 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OneDriveBackupClient = exports.OneDriveBackupStorage = exports.MICROSOFT_GRAPH_DRIVE = void 0;
|
|
4
|
+
const microsoft_js_1 = require("../auth/microsoft.js");
|
|
5
|
+
exports.MICROSOFT_GRAPH_DRIVE = 'https://graph.microsoft.com/v1.0/me/drive';
|
|
6
|
+
const BACKUP_FOLDER = 'backups';
|
|
7
|
+
class OneDriveBackupStorage {
|
|
8
|
+
getAccessToken;
|
|
9
|
+
fetcher;
|
|
10
|
+
constructor(getAccessToken, fetcher = fetch) {
|
|
11
|
+
this.getAccessToken = getAccessToken;
|
|
12
|
+
this.fetcher = fetcher;
|
|
13
|
+
}
|
|
14
|
+
async save(fileName, data) {
|
|
15
|
+
const token = await this.getAccessToken();
|
|
16
|
+
const encodedName = encodeURIComponent(fileName);
|
|
17
|
+
const url = `${exports.MICROSOFT_GRAPH_DRIVE}/special/approot:/${BACKUP_FOLDER}/${encodedName}:/content`;
|
|
18
|
+
const response = await this.fetcher(url, {
|
|
19
|
+
method: 'PUT',
|
|
20
|
+
headers: {
|
|
21
|
+
Authorization: `Bearer ${token}`,
|
|
22
|
+
'Content-Type': 'application/octet-stream',
|
|
23
|
+
},
|
|
24
|
+
body: data.slice().buffer,
|
|
25
|
+
});
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
let detail = '';
|
|
28
|
+
try {
|
|
29
|
+
const body = (await response.json());
|
|
30
|
+
detail = body?.error?.message || response.statusText;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
detail = response.statusText;
|
|
34
|
+
}
|
|
35
|
+
throw new Error(`OneDrive upload failed (${response.status}): ${detail}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async read(fileIdOrName) {
|
|
39
|
+
const token = await this.getAccessToken();
|
|
40
|
+
let url = `${exports.MICROSOFT_GRAPH_DRIVE}/items/${encodeURIComponent(fileIdOrName)}/content`;
|
|
41
|
+
// Support reading directly by filename inside approot:/backups/
|
|
42
|
+
if (fileIdOrName.endsWith('.db') && !fileIdOrName.startsWith('item-')) {
|
|
43
|
+
url = `${exports.MICROSOFT_GRAPH_DRIVE}/special/approot:/${BACKUP_FOLDER}/${encodeURIComponent(fileIdOrName)}:/content`;
|
|
44
|
+
}
|
|
45
|
+
const response = await this.fetcher(url, {
|
|
46
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
47
|
+
});
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
throw new Error(`OneDrive download failed (${response.status}): ${response.statusText}`);
|
|
50
|
+
}
|
|
51
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
52
|
+
}
|
|
53
|
+
async list() {
|
|
54
|
+
const token = await this.getAccessToken();
|
|
55
|
+
const url = `${exports.MICROSOFT_GRAPH_DRIVE}/special/approot:/${BACKUP_FOLDER}:/children?$select=id,name,size,lastModifiedDateTime`;
|
|
56
|
+
const response = await this.fetcher(url, {
|
|
57
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
58
|
+
});
|
|
59
|
+
if (response.status === 404) {
|
|
60
|
+
return []; // Folder does not exist yet
|
|
61
|
+
}
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
throw new Error(`OneDrive list failed (${response.status}): ${response.statusText}`);
|
|
64
|
+
}
|
|
65
|
+
const result = (await response.json());
|
|
66
|
+
return (result.value ?? [])
|
|
67
|
+
.filter(item => item.name.endsWith('.db'))
|
|
68
|
+
.map(item => ({
|
|
69
|
+
id: item.id,
|
|
70
|
+
name: item.name,
|
|
71
|
+
size: item.size,
|
|
72
|
+
modifiedAt: item.lastModifiedDateTime,
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
exports.OneDriveBackupStorage = OneDriveBackupStorage;
|
|
77
|
+
class OneDriveBackupClient {
|
|
78
|
+
storage;
|
|
79
|
+
authClient;
|
|
80
|
+
constructor(options) {
|
|
81
|
+
const scopes = options.scopes ?? [
|
|
82
|
+
'openid',
|
|
83
|
+
'profile',
|
|
84
|
+
'email',
|
|
85
|
+
'User.Read',
|
|
86
|
+
'Files.ReadWrite.AppFolder',
|
|
87
|
+
];
|
|
88
|
+
this.authClient = new microsoft_js_1.MicrosoftAuthClient({
|
|
89
|
+
...options,
|
|
90
|
+
scopes,
|
|
91
|
+
});
|
|
92
|
+
this.storage = new OneDriveBackupStorage(() => this.authClient.getValidAccessToken(), options.fetch ?? fetch);
|
|
93
|
+
}
|
|
94
|
+
async connect() {
|
|
95
|
+
const user = await this.authClient.signIn();
|
|
96
|
+
return user !== null;
|
|
97
|
+
}
|
|
98
|
+
async disconnect() {
|
|
99
|
+
await this.authClient.signOut();
|
|
100
|
+
}
|
|
101
|
+
async isConnected() {
|
|
102
|
+
return this.authClient.isSignedIn();
|
|
103
|
+
}
|
|
104
|
+
save(fileName, data) {
|
|
105
|
+
return this.storage.save(fileName, data);
|
|
106
|
+
}
|
|
107
|
+
read(fileId) {
|
|
108
|
+
return this.storage.read(fileId);
|
|
109
|
+
}
|
|
110
|
+
list() {
|
|
111
|
+
return this.storage.list();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
exports.OneDriveBackupClient = OneDriveBackupClient;
|
package/dist/core/client.d.ts
CHANGED
|
@@ -2,10 +2,12 @@ import { LocalDBAdapter, LocalTransaction } from '../adapters/local/index.js';
|
|
|
2
2
|
import { RemoteDBAdapter } from '../adapters/remote/index.js';
|
|
3
3
|
import { EventEmitter } from '../reactive/event-emitter.js';
|
|
4
4
|
import { SyncWorker } from './sync-worker.js';
|
|
5
|
+
import { DatabaseMaintenance } from './maintenance.js';
|
|
5
6
|
import { SchemaManager } from './schema-manager.js';
|
|
6
7
|
import { BatchMutation, ConflictResolver, ConflictStrategy, MutateOptions, NetworkStatusProvider, OutboxRecord, OutboxStatus, QueryOptions, RetryConfig, SyncMetrics, SyncOp, TableChangeEvent, WhereFilter } from './types.js';
|
|
7
8
|
import { SyncLogger, CustomLogger, LogLevel } from '../utils/logger.js';
|
|
8
9
|
import { QueryBuilder, AggregateSpec, AggregateResult } from './query-builder.js';
|
|
10
|
+
import { type ExportJsonOptions, type ImportJsonOptions, type ExportCsvOptions, type ImportCsvOptions, type ImportSummary } from '../utils/portability.js';
|
|
9
11
|
export interface LocalFirstClientConfig {
|
|
10
12
|
local: LocalDBAdapter;
|
|
11
13
|
remote: RemoteDBAdapter;
|
|
@@ -61,6 +63,10 @@ export declare class TransactionalClient {
|
|
|
61
63
|
includeDeleted?: boolean;
|
|
62
64
|
}): Promise<T[]>;
|
|
63
65
|
findMany<T = any>(tableName: string, options?: QueryOptions): Promise<T[]>;
|
|
66
|
+
/**
|
|
67
|
+
* Alias for findMany().
|
|
68
|
+
*/
|
|
69
|
+
find<T = any>(tableName: string, options?: QueryOptions): Promise<T[]>;
|
|
64
70
|
query<T = any>(sql: string, params?: any[]): Promise<T[]>;
|
|
65
71
|
from<T = any>(tableName: string): QueryBuilder<T>;
|
|
66
72
|
get rawTx(): LocalTransaction;
|
|
@@ -78,6 +84,7 @@ export declare class LocalFirstClient {
|
|
|
78
84
|
autoHydrateJSON: boolean;
|
|
79
85
|
/** Local column stamped on INSERT/UPDATE writes, or null when disabled. */
|
|
80
86
|
touchColumn: string | null;
|
|
87
|
+
readonly maintenance: DatabaseMaintenance;
|
|
81
88
|
private primaryKey;
|
|
82
89
|
private migrations;
|
|
83
90
|
private autoStart;
|
|
@@ -225,6 +232,32 @@ export declare class LocalFirstClient {
|
|
|
225
232
|
*/
|
|
226
233
|
aggregate(tableName: string, spec: AggregateSpec): Promise<AggregateResult>;
|
|
227
234
|
findMany<T = any>(tableName: string, options?: QueryOptions): Promise<T[]>;
|
|
235
|
+
/**
|
|
236
|
+
* Alias for findMany().
|
|
237
|
+
*/
|
|
238
|
+
find<T = any>(tableName: string, options?: QueryOptions): Promise<T[]>;
|
|
239
|
+
/**
|
|
240
|
+
* Returns true if client storage has successfully initialized.
|
|
241
|
+
*/
|
|
242
|
+
get isReady(): boolean;
|
|
243
|
+
/**
|
|
244
|
+
* Convenience export of tables to JSON.
|
|
245
|
+
*/
|
|
246
|
+
exportToJson(tables?: string[], options?: ExportJsonOptions): Promise<string>;
|
|
247
|
+
/**
|
|
248
|
+
* Convenience import of tables from JSON.
|
|
249
|
+
*/
|
|
250
|
+
importFromJson(jsonString: string, options?: ImportJsonOptions): Promise<ImportSummary>;
|
|
251
|
+
/**
|
|
252
|
+
* Convenience export of a table to RFC-4180 CSV.
|
|
253
|
+
*/
|
|
254
|
+
exportToCsv(tableName: string, options?: ExportCsvOptions): Promise<string>;
|
|
255
|
+
/**
|
|
256
|
+
* Convenience import of RFC-4180 CSV into a table.
|
|
257
|
+
*/
|
|
258
|
+
importFromCsv(tableName: string, csvString: string, options?: ImportCsvOptions): Promise<{
|
|
259
|
+
rowCount: number;
|
|
260
|
+
}>;
|
|
228
261
|
/**
|
|
229
262
|
* Finds a single record by primary key.
|
|
230
263
|
*
|
package/dist/core/client.js
CHANGED
|
@@ -6,11 +6,13 @@ const sync_worker_js_1 = require("./sync-worker.js");
|
|
|
6
6
|
const table_manager_js_1 = require("./table-manager.js");
|
|
7
7
|
const outbox_store_js_1 = require("./outbox-store.js");
|
|
8
8
|
const meta_store_js_1 = require("./meta-store.js");
|
|
9
|
+
const maintenance_js_1 = require("./maintenance.js");
|
|
9
10
|
const schema_manager_js_1 = require("./schema-manager.js");
|
|
10
11
|
const uuid_js_1 = require("../utils/uuid.js");
|
|
11
12
|
const sql_js_1 = require("../utils/sql.js");
|
|
12
13
|
const query_js_1 = require("./query.js");
|
|
13
14
|
const query_builder_js_1 = require("./query-builder.js");
|
|
15
|
+
const portability_js_1 = require("../utils/portability.js");
|
|
14
16
|
function hasFunctionFilter(value) {
|
|
15
17
|
if (typeof value === 'function')
|
|
16
18
|
return true;
|
|
@@ -132,6 +134,12 @@ class TransactionalClient {
|
|
|
132
134
|
}
|
|
133
135
|
return this.from(tableName).applyOptions(options).execute();
|
|
134
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* Alias for findMany().
|
|
139
|
+
*/
|
|
140
|
+
async find(tableName, options) {
|
|
141
|
+
return this.findMany(tableName, options);
|
|
142
|
+
}
|
|
135
143
|
async query(sql, params = []) {
|
|
136
144
|
return (await this.tx.execute(sql, params));
|
|
137
145
|
}
|
|
@@ -156,6 +164,7 @@ class LocalFirstClient {
|
|
|
156
164
|
autoHydrateJSON;
|
|
157
165
|
/** Local column stamped on INSERT/UPDATE writes, or null when disabled. */
|
|
158
166
|
touchColumn;
|
|
167
|
+
maintenance;
|
|
159
168
|
primaryKey;
|
|
160
169
|
migrations;
|
|
161
170
|
autoStart;
|
|
@@ -180,6 +189,7 @@ class LocalFirstClient {
|
|
|
180
189
|
? touch
|
|
181
190
|
: this.remote?.updatedAtColumn || 'updated_at';
|
|
182
191
|
this.schemaManager = new schema_manager_js_1.SchemaManager();
|
|
192
|
+
this.maintenance = new maintenance_js_1.DatabaseMaintenance(this.db, () => this.tables);
|
|
183
193
|
const workerOptions = {
|
|
184
194
|
network: config.network,
|
|
185
195
|
conflictResolver: config.conflictResolver,
|
|
@@ -712,6 +722,42 @@ class LocalFirstClient {
|
|
|
712
722
|
}
|
|
713
723
|
return this.from(tableName).applyOptions(options).execute();
|
|
714
724
|
}
|
|
725
|
+
/**
|
|
726
|
+
* Alias for findMany().
|
|
727
|
+
*/
|
|
728
|
+
async find(tableName, options) {
|
|
729
|
+
return this.findMany(tableName, options);
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Returns true if client storage has successfully initialized.
|
|
733
|
+
*/
|
|
734
|
+
get isReady() {
|
|
735
|
+
return Boolean(this.initialization);
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Convenience export of tables to JSON.
|
|
739
|
+
*/
|
|
740
|
+
async exportToJson(tables, options) {
|
|
741
|
+
return (0, portability_js_1.exportTablesToJson)(this, tables, options);
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Convenience import of tables from JSON.
|
|
745
|
+
*/
|
|
746
|
+
async importFromJson(jsonString, options) {
|
|
747
|
+
return (0, portability_js_1.importTablesFromJson)(this, jsonString, options);
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Convenience export of a table to RFC-4180 CSV.
|
|
751
|
+
*/
|
|
752
|
+
async exportToCsv(tableName, options) {
|
|
753
|
+
return (0, portability_js_1.exportTableToCsv)(this, tableName, options);
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Convenience import of RFC-4180 CSV into a table.
|
|
757
|
+
*/
|
|
758
|
+
async importFromCsv(tableName, csvString, options) {
|
|
759
|
+
return (0, portability_js_1.importTableFromCsv)(this, tableName, csvString, options);
|
|
760
|
+
}
|
|
715
761
|
/**
|
|
716
762
|
* Finds a single record by primary key.
|
|
717
763
|
*
|
package/dist/core/conflict.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import { ConflictResolver, ConflictStrategy } from './types.js';
|
|
1
|
+
import { ConflictContext, ConflictResolution, ConflictResolver, ConflictStrategy, FieldMergeResolverOptions } from './types.js';
|
|
2
|
+
export type { ConflictContext, ConflictResolution, ConflictResolver, ConflictStrategy, FieldMergeResolverOptions, };
|
|
2
3
|
export declare const lastWriteWinsResolver: ConflictResolver;
|
|
3
4
|
export declare const serverWinsResolver: ConflictResolver;
|
|
4
5
|
export declare const clientWinsResolver: ConflictResolver;
|
|
6
|
+
/**
|
|
7
|
+
* Creates a smart 3-way field-level conflict resolver.
|
|
8
|
+
* Merges disjoint fields from both local and remote offline mutations,
|
|
9
|
+
* and resolves overlapping field changes using custom rules, timestamps, or winner strategies.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createFieldMergeResolver(options?: FieldMergeResolverOptions): ConflictResolver;
|
|
5
12
|
export declare function getConflictResolver(strategyOrResolver?: ConflictStrategy | ConflictResolver): ConflictResolver;
|
package/dist/core/conflict.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.clientWinsResolver = exports.serverWinsResolver = exports.lastWriteWinsResolver = void 0;
|
|
4
|
+
exports.createFieldMergeResolver = createFieldMergeResolver;
|
|
4
5
|
exports.getConflictResolver = getConflictResolver;
|
|
5
6
|
const lastWriteWinsResolver = (context) => {
|
|
6
7
|
const remoteTime = context.remoteChange.timestamp ?? 0;
|
|
@@ -22,6 +23,78 @@ const clientWinsResolver = (context) => {
|
|
|
22
23
|
return 'apply-remote';
|
|
23
24
|
};
|
|
24
25
|
exports.clientWinsResolver = clientWinsResolver;
|
|
26
|
+
/**
|
|
27
|
+
* Creates a smart 3-way field-level conflict resolver.
|
|
28
|
+
* Merges disjoint fields from both local and remote offline mutations,
|
|
29
|
+
* and resolves overlapping field changes using custom rules, timestamps, or winner strategies.
|
|
30
|
+
*/
|
|
31
|
+
function createFieldMergeResolver(options = {}) {
|
|
32
|
+
const defaultWinner = options.defaultWinner ?? 'newer';
|
|
33
|
+
const deleteHandling = options.deleteHandling ?? 'delete-wins';
|
|
34
|
+
const fieldRules = options.fieldRules ?? {};
|
|
35
|
+
return (context) => {
|
|
36
|
+
const isRemoteDelete = context.remoteChange.operation === 'DELETE';
|
|
37
|
+
const isLocalDelete = context.localOutboxRecord?.operation === 'DELETE';
|
|
38
|
+
if (isRemoteDelete && isLocalDelete) {
|
|
39
|
+
return 'apply-remote';
|
|
40
|
+
}
|
|
41
|
+
if (isRemoteDelete && !isLocalDelete) {
|
|
42
|
+
return deleteHandling === 'update-wins' ? 'keep-local' : 'apply-remote';
|
|
43
|
+
}
|
|
44
|
+
if (isLocalDelete && !isRemoteDelete) {
|
|
45
|
+
return deleteHandling === 'update-wins' ? 'apply-remote' : 'keep-local';
|
|
46
|
+
}
|
|
47
|
+
// Both are non-delete operations (INSERT / UPDATE)
|
|
48
|
+
const baseRow = context.localRow ?? {};
|
|
49
|
+
const remotePayload = context.remoteChange.payload ?? {};
|
|
50
|
+
const localPayload = context.localOutboxRecord?.payload ?? {};
|
|
51
|
+
const merged = { ...baseRow, ...remotePayload };
|
|
52
|
+
const tableRules = fieldRules[context.tableName] ?? {};
|
|
53
|
+
const wildcardRules = fieldRules['*'] ?? {};
|
|
54
|
+
const localTime = context.localOutboxRecord?.timestamp ?? 0;
|
|
55
|
+
const remoteTime = context.remoteChange.timestamp ?? 0;
|
|
56
|
+
for (const key of Object.keys(localPayload)) {
|
|
57
|
+
if (!(key in remotePayload)) {
|
|
58
|
+
// Field only changed locally: preserve local modification
|
|
59
|
+
merged[key] = localPayload[key];
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
// Field was modified by BOTH local and remote: resolve conflict
|
|
63
|
+
const rule = tableRules[key] ?? wildcardRules[key];
|
|
64
|
+
const localVal = localPayload[key];
|
|
65
|
+
const remoteVal = remotePayload[key];
|
|
66
|
+
if (typeof rule === 'function') {
|
|
67
|
+
merged[key] = rule(localVal, remoteVal, context);
|
|
68
|
+
}
|
|
69
|
+
else if (rule === 'max') {
|
|
70
|
+
merged[key] = localVal >= remoteVal ? localVal : remoteVal;
|
|
71
|
+
}
|
|
72
|
+
else if (rule === 'min') {
|
|
73
|
+
merged[key] = localVal <= remoteVal ? localVal : remoteVal;
|
|
74
|
+
}
|
|
75
|
+
else if (rule === 'local') {
|
|
76
|
+
merged[key] = localVal;
|
|
77
|
+
}
|
|
78
|
+
else if (rule === 'remote') {
|
|
79
|
+
merged[key] = remoteVal;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const winner = rule === 'newer' ? 'newer' : defaultWinner;
|
|
83
|
+
if (winner === 'local') {
|
|
84
|
+
merged[key] = localVal;
|
|
85
|
+
}
|
|
86
|
+
else if (winner === 'remote') {
|
|
87
|
+
merged[key] = remoteVal;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
// 'newer': compare timestamp
|
|
91
|
+
merged[key] = localTime >= remoteTime ? localVal : remoteVal;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { mergePayload: merged };
|
|
96
|
+
};
|
|
97
|
+
}
|
|
25
98
|
function getConflictResolver(strategyOrResolver) {
|
|
26
99
|
if (typeof strategyOrResolver === 'function') {
|
|
27
100
|
return strategyOrResolver;
|
|
@@ -31,6 +104,8 @@ function getConflictResolver(strategyOrResolver) {
|
|
|
31
104
|
return exports.serverWinsResolver;
|
|
32
105
|
case 'client-wins':
|
|
33
106
|
return exports.clientWinsResolver;
|
|
107
|
+
case 'field-merge':
|
|
108
|
+
return createFieldMergeResolver();
|
|
34
109
|
case 'last-write-wins':
|
|
35
110
|
default:
|
|
36
111
|
return exports.lastWriteWinsResolver;
|
package/dist/core/index.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * from './meta-store.js';
|
|
|
8
8
|
export * from './table-manager.js';
|
|
9
9
|
export * from './schema-manager.js';
|
|
10
10
|
export * from './query-builder.js';
|
|
11
|
+
export * from './maintenance.js';
|
|
11
12
|
export * from '../reactive/event-emitter.js';
|
|
12
13
|
export * from '../adapters/network/index.js';
|
|
13
14
|
export type { LocalDBAdapter, LocalTransaction } from '../adapters/local/index.js';
|
package/dist/core/index.js
CHANGED
|
@@ -25,6 +25,7 @@ __exportStar(require("./meta-store.js"), exports);
|
|
|
25
25
|
__exportStar(require("./table-manager.js"), exports);
|
|
26
26
|
__exportStar(require("./schema-manager.js"), exports);
|
|
27
27
|
__exportStar(require("./query-builder.js"), exports);
|
|
28
|
+
__exportStar(require("./maintenance.js"), exports);
|
|
28
29
|
__exportStar(require("../reactive/event-emitter.js"), exports);
|
|
29
30
|
__exportStar(require("../adapters/network/index.js"), exports);
|
|
30
31
|
__exportStar(require("../utils/logger.js"), exports);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { LocalDBAdapter } from '../adapters/local/index.js';
|
|
2
|
+
import { OutboxStatus } from './types.js';
|
|
3
|
+
export interface PruneOutboxOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Only delete outbox records older than this many days.
|
|
6
|
+
* e.g. retentionDays: 7 deletes records older than 7 days.
|
|
7
|
+
*/
|
|
8
|
+
retentionDays?: number;
|
|
9
|
+
/**
|
|
10
|
+
* Explicit cutoff timestamp in milliseconds. Records older than this timestamp are eligible.
|
|
11
|
+
*/
|
|
12
|
+
beforeTimestamp?: number;
|
|
13
|
+
/**
|
|
14
|
+
* Maximum number of outbox entries to retain. If the outbox exceeds this count,
|
|
15
|
+
* the oldest matching records are pruned.
|
|
16
|
+
*/
|
|
17
|
+
maxEntries?: number;
|
|
18
|
+
/**
|
|
19
|
+
* Target statuses to prune. Defaults to ['COMPLETED', 'FAILED'].
|
|
20
|
+
* PENDING records are protected from pruning by default unless explicitly included.
|
|
21
|
+
*/
|
|
22
|
+
statuses?: OutboxStatus[];
|
|
23
|
+
}
|
|
24
|
+
export interface OutboxStorageStats {
|
|
25
|
+
total: number;
|
|
26
|
+
pending: number;
|
|
27
|
+
processing: number;
|
|
28
|
+
completed: number;
|
|
29
|
+
failed: number;
|
|
30
|
+
}
|
|
31
|
+
export interface StorageStats {
|
|
32
|
+
/** Row count per table */
|
|
33
|
+
tables: Record<string, number>;
|
|
34
|
+
/** Breakdown of sync outbox items */
|
|
35
|
+
outbox: OutboxStorageStats;
|
|
36
|
+
/** SQLite allocation page size in bytes */
|
|
37
|
+
pageSize: number;
|
|
38
|
+
/** Total allocated SQLite database pages */
|
|
39
|
+
pageCount: number;
|
|
40
|
+
/** Total calculated database size on disk in bytes */
|
|
41
|
+
databaseSizeBytes: number;
|
|
42
|
+
}
|
|
43
|
+
export type CheckpointMode = 'PASSIVE' | 'FULL' | 'RESTART' | 'TRUNCATE';
|
|
44
|
+
export declare class DatabaseMaintenance {
|
|
45
|
+
private readonly db;
|
|
46
|
+
private readonly getKnownTables;
|
|
47
|
+
constructor(db: LocalDBAdapter, getKnownTables?: () => string[]);
|
|
48
|
+
/**
|
|
49
|
+
* Reclaims unused space, defragments the database file, and optimizes internal B-trees.
|
|
50
|
+
*/
|
|
51
|
+
vacuum(): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Checkpoints WAL (Write-Ahead Log) pages into the main database file.
|
|
54
|
+
* 'TRUNCATE' (default) copies all WAL frames to the database file and resets WAL size to zero.
|
|
55
|
+
*/
|
|
56
|
+
checkpoint(mode?: CheckpointMode): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Prunes old or completed mutations from the sync_outbox table to keep database footprint small.
|
|
59
|
+
* Returns the count of deleted outbox records.
|
|
60
|
+
*/
|
|
61
|
+
pruneOutbox(options?: PruneOutboxOptions): Promise<number>;
|
|
62
|
+
/**
|
|
63
|
+
* Gathers storage metrics: table row counts, outbox status distribution, and database size.
|
|
64
|
+
*/
|
|
65
|
+
getStorageStats(): Promise<StorageStats>;
|
|
66
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DatabaseMaintenance = void 0;
|
|
4
|
+
const sql_js_1 = require("../utils/sql.js");
|
|
5
|
+
class DatabaseMaintenance {
|
|
6
|
+
db;
|
|
7
|
+
getKnownTables;
|
|
8
|
+
constructor(db, getKnownTables = () => []) {
|
|
9
|
+
this.db = db;
|
|
10
|
+
this.getKnownTables = getKnownTables;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Reclaims unused space, defragments the database file, and optimizes internal B-trees.
|
|
14
|
+
*/
|
|
15
|
+
async vacuum() {
|
|
16
|
+
await this.db.execute('VACUUM');
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Checkpoints WAL (Write-Ahead Log) pages into the main database file.
|
|
20
|
+
* 'TRUNCATE' (default) copies all WAL frames to the database file and resets WAL size to zero.
|
|
21
|
+
*/
|
|
22
|
+
async checkpoint(mode = 'TRUNCATE') {
|
|
23
|
+
await this.db.execute(`PRAGMA wal_checkpoint(${mode})`);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Prunes old or completed mutations from the sync_outbox table to keep database footprint small.
|
|
27
|
+
* Returns the count of deleted outbox records.
|
|
28
|
+
*/
|
|
29
|
+
async pruneOutbox(options = {}) {
|
|
30
|
+
const statuses = options.statuses ?? ['COMPLETED', 'FAILED'];
|
|
31
|
+
if (statuses.length === 0)
|
|
32
|
+
return 0;
|
|
33
|
+
let cutoffTimestamp = options.beforeTimestamp;
|
|
34
|
+
if (options.retentionDays !== undefined && options.retentionDays >= 0) {
|
|
35
|
+
const daysMs = options.retentionDays * 24 * 60 * 60 * 1000;
|
|
36
|
+
const calculatedCutoff = Date.now() - daysMs;
|
|
37
|
+
cutoffTimestamp = cutoffTimestamp !== undefined
|
|
38
|
+
? Math.min(cutoffTimestamp, calculatedCutoff)
|
|
39
|
+
: calculatedCutoff;
|
|
40
|
+
}
|
|
41
|
+
const conditions = [];
|
|
42
|
+
const params = [];
|
|
43
|
+
const statusPlaceholders = statuses.map(() => '?').join(', ');
|
|
44
|
+
conditions.push(`status IN (${statusPlaceholders})`);
|
|
45
|
+
params.push(...statuses);
|
|
46
|
+
if (cutoffTimestamp !== undefined) {
|
|
47
|
+
conditions.push('timestamp < ?');
|
|
48
|
+
params.push(cutoffTimestamp);
|
|
49
|
+
}
|
|
50
|
+
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
|
51
|
+
// If maxEntries is set, only prune if total matching exceeds maxEntries
|
|
52
|
+
if (options.maxEntries !== undefined && options.maxEntries >= 0) {
|
|
53
|
+
const countRes = await this.db.execute(`SELECT COUNT(*) as total FROM sync_outbox ${whereClause}`, params);
|
|
54
|
+
const totalMatching = Number(countRes[0]?.total ?? 0);
|
|
55
|
+
const excess = totalMatching - options.maxEntries;
|
|
56
|
+
if (excess <= 0) {
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
// Delete the oldest `excess` records
|
|
60
|
+
const selectOldest = await this.db.execute(`SELECT id FROM sync_outbox ${whereClause} ORDER BY timestamp ASC LIMIT ?`, [...params, excess]);
|
|
61
|
+
if (selectOldest.length === 0)
|
|
62
|
+
return 0;
|
|
63
|
+
const ids = selectOldest.map((r) => r.id);
|
|
64
|
+
const idPlaceholders = ids.map(() => '?').join(', ');
|
|
65
|
+
await this.db.execute(`DELETE FROM sync_outbox WHERE id IN (${idPlaceholders})`, ids);
|
|
66
|
+
return ids.length;
|
|
67
|
+
}
|
|
68
|
+
// Standard cutoff delete
|
|
69
|
+
const beforeCountRes = await this.db.execute(`SELECT COUNT(*) as total FROM sync_outbox ${whereClause}`, params);
|
|
70
|
+
const toDeleteCount = Number(beforeCountRes[0]?.total ?? 0);
|
|
71
|
+
if (toDeleteCount === 0)
|
|
72
|
+
return 0;
|
|
73
|
+
await this.db.execute(`DELETE FROM sync_outbox ${whereClause}`, params);
|
|
74
|
+
return toDeleteCount;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Gathers storage metrics: table row counts, outbox status distribution, and database size.
|
|
78
|
+
*/
|
|
79
|
+
async getStorageStats() {
|
|
80
|
+
// 1. Table row counts
|
|
81
|
+
const tableCounts = {};
|
|
82
|
+
let tableNames = [];
|
|
83
|
+
try {
|
|
84
|
+
const masterRows = await this.db.execute(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`);
|
|
85
|
+
tableNames = masterRows.map((r) => r.name);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
tableNames = this.getKnownTables();
|
|
89
|
+
}
|
|
90
|
+
for (const tableName of tableNames) {
|
|
91
|
+
try {
|
|
92
|
+
const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
|
|
93
|
+
const rows = await this.db.execute(`SELECT COUNT(*) as count FROM ${safeTable}`);
|
|
94
|
+
tableCounts[tableName] = Number(rows[0]?.count ?? 0);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Table might not exist or be accessible
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// 2. Outbox breakdown
|
|
101
|
+
const outboxStats = {
|
|
102
|
+
total: 0,
|
|
103
|
+
pending: 0,
|
|
104
|
+
processing: 0,
|
|
105
|
+
completed: 0,
|
|
106
|
+
failed: 0,
|
|
107
|
+
};
|
|
108
|
+
try {
|
|
109
|
+
const outboxRows = await this.db.execute(`SELECT status, COUNT(*) as count FROM sync_outbox GROUP BY status`);
|
|
110
|
+
for (const row of outboxRows) {
|
|
111
|
+
const st = String(row.status).toUpperCase();
|
|
112
|
+
const cnt = Number(row.count ?? 0);
|
|
113
|
+
outboxStats.total += cnt;
|
|
114
|
+
if (st === 'PENDING')
|
|
115
|
+
outboxStats.pending = cnt;
|
|
116
|
+
else if (st === 'PROCESSING')
|
|
117
|
+
outboxStats.processing = cnt;
|
|
118
|
+
else if (st === 'COMPLETED')
|
|
119
|
+
outboxStats.completed = cnt;
|
|
120
|
+
else if (st === 'FAILED')
|
|
121
|
+
outboxStats.failed = cnt;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// sync_outbox table may not exist yet
|
|
126
|
+
}
|
|
127
|
+
// 3. SQLite database sizing via PRAGMAs
|
|
128
|
+
let pageSize = 4096;
|
|
129
|
+
let pageCount = 0;
|
|
130
|
+
try {
|
|
131
|
+
const pageSizeRes = await this.db.execute('PRAGMA page_size');
|
|
132
|
+
if (pageSizeRes[0]) {
|
|
133
|
+
pageSize = Number(Object.values(pageSizeRes[0])[0] ?? 4096);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// fallback
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const pageCountRes = await this.db.execute('PRAGMA page_count');
|
|
141
|
+
if (pageCountRes[0]) {
|
|
142
|
+
pageCount = Number(Object.values(pageCountRes[0])[0] ?? 0);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// fallback
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
tables: tableCounts,
|
|
150
|
+
outbox: outboxStats,
|
|
151
|
+
pageSize,
|
|
152
|
+
pageCount,
|
|
153
|
+
databaseSizeBytes: pageSize * pageCount,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
exports.DatabaseMaintenance = DatabaseMaintenance;
|
package/dist/core/sync-worker.js
CHANGED
|
@@ -7,6 +7,7 @@ const outbox_store_js_1 = require("./outbox-store.js");
|
|
|
7
7
|
const meta_store_js_1 = require("./meta-store.js");
|
|
8
8
|
const table_manager_js_1 = require("./table-manager.js");
|
|
9
9
|
const logger_js_1 = require("../utils/logger.js");
|
|
10
|
+
const dynamic_js_1 = require("../utils/dynamic.js");
|
|
10
11
|
const REACT_NATIVE_PKG = ['react', 'native'].join('-');
|
|
11
12
|
/** Maximum outbox records sent per push cycle. */
|
|
12
13
|
const PUSH_BATCH_SIZE = 50;
|
|
@@ -236,7 +237,8 @@ class SyncWorker {
|
|
|
236
237
|
return false;
|
|
237
238
|
// React Native AppState
|
|
238
239
|
try {
|
|
239
|
-
const
|
|
240
|
+
const rn = (0, dynamic_js_1.safeDynamicRequire)(REACT_NATIVE_PKG);
|
|
241
|
+
const AppState = rn?.AppState;
|
|
240
242
|
if (AppState?.currentState && typeof AppState.currentState === 'string') {
|
|
241
243
|
return Boolean(AppState.currentState.match(/inactive|background/));
|
|
242
244
|
}
|
|
@@ -255,7 +257,8 @@ class SyncWorker {
|
|
|
255
257
|
return;
|
|
256
258
|
// 1. Try React Native AppState
|
|
257
259
|
try {
|
|
258
|
-
const
|
|
260
|
+
const rn = (0, dynamic_js_1.safeDynamicRequire)(REACT_NATIVE_PKG);
|
|
261
|
+
const AppState = rn?.AppState;
|
|
259
262
|
if (AppState && typeof AppState.addEventListener === 'function') {
|
|
260
263
|
const sub = AppState.addEventListener('change', (state) => {
|
|
261
264
|
if (state.match(/inactive|background/)) {
|
|
@@ -654,7 +657,7 @@ class SyncWorker {
|
|
|
654
657
|
}
|
|
655
658
|
}
|
|
656
659
|
// 3. Apply remote change to local database
|
|
657
|
-
await table_manager_js_1.TableManager.applyMutation(tx, change.tableName, change.rowId, change.operation, change.payload, this.primaryKey, { autoSchema: this.autoSchema, schemaManager: this.schemaManager });
|
|
660
|
+
await table_manager_js_1.TableManager.applyMutation(tx, change.tableName, change.rowId, change.operation, change.payload ?? null, this.primaryKey, { autoSchema: this.autoSchema, schemaManager: this.schemaManager });
|
|
658
661
|
// 4. Queue table change event to notify reactive listeners after commit
|
|
659
662
|
queuedEvents.push({
|
|
660
663
|
event: change.tableName,
|