local-lite 0.0.12 → 0.0.14

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.
@@ -0,0 +1,18 @@
1
+ import type { BackupDatabase, BackupStorage } from './index.js';
2
+ export interface EncryptedBackupOptions {
3
+ passphrase: string;
4
+ fileName?: string;
5
+ iterations?: number;
6
+ }
7
+ export interface RestoreEncryptedBackupOptions {
8
+ passphrase: string;
9
+ }
10
+ export declare function isEncryptedBackup(data: Uint8Array): boolean;
11
+ export declare function encryptBackupData(plainData: Uint8Array, passphrase: string, options?: {
12
+ iterations?: number;
13
+ }): Promise<Uint8Array>;
14
+ export declare function decryptBackupData(encryptedData: Uint8Array, passphrase: string, options?: {
15
+ iterations?: number;
16
+ }): Promise<Uint8Array>;
17
+ export declare function createEncryptedDatabaseBackup(database: BackupDatabase, storage: BackupStorage, options: EncryptedBackupOptions): Promise<string>;
18
+ export declare function restoreEncryptedDatabaseBackup(database: BackupDatabase, storage: BackupStorage, fileName: string, options: RestoreEncryptedBackupOptions): Promise<void>;
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isEncryptedBackup = isEncryptedBackup;
4
+ exports.encryptBackupData = encryptBackupData;
5
+ exports.decryptBackupData = decryptBackupData;
6
+ exports.createEncryptedDatabaseBackup = createEncryptedDatabaseBackup;
7
+ exports.restoreEncryptedDatabaseBackup = restoreEncryptedDatabaseBackup;
8
+ const pbkdf2_js_1 = require("@noble/hashes/pbkdf2.js");
9
+ const sha2_js_1 = require("@noble/hashes/sha2.js");
10
+ const utils_js_1 = require("@noble/hashes/utils.js");
11
+ const MAGIC_HEADER = new Uint8Array([0x4c, 0x4c, 0x45, 0x4e, 0x43, 0x31]); // "LLENC1"
12
+ const SALT_LENGTH = 16;
13
+ const IV_LENGTH = 12;
14
+ const DEFAULT_ITERATIONS = 50000;
15
+ function getRandomBytes(length) {
16
+ const crypto = globalThis.crypto;
17
+ if (!crypto?.getRandomValues) {
18
+ throw new Error('Secure random generation is unavailable for backup encryption.');
19
+ }
20
+ return crypto.getRandomValues(new Uint8Array(length));
21
+ }
22
+ const ITERATIONS_LENGTH = 4;
23
+ function isEncryptedBackup(data) {
24
+ if (data.length < MAGIC_HEADER.length + ITERATIONS_LENGTH + SALT_LENGTH + IV_LENGTH + 16) {
25
+ return false;
26
+ }
27
+ for (let i = 0; i < MAGIC_HEADER.length; i++) {
28
+ if (data[i] !== MAGIC_HEADER[i])
29
+ return false;
30
+ }
31
+ return true;
32
+ }
33
+ async function encryptBackupData(plainData, passphrase, options = {}) {
34
+ if (!passphrase || !passphrase.trim()) {
35
+ throw new Error('A non-empty passphrase is required for backup encryption.');
36
+ }
37
+ const iterations = options.iterations ?? DEFAULT_ITERATIONS;
38
+ const salt = getRandomBytes(SALT_LENGTH);
39
+ const iv = getRandomBytes(IV_LENGTH);
40
+ // Derive 256-bit AES key using PBKDF2 with SHA-256
41
+ const keyBytes = (0, pbkdf2_js_1.pbkdf2)(sha2_js_1.sha256, (0, utils_js_1.utf8ToBytes)(passphrase), salt, {
42
+ c: iterations,
43
+ dkLen: 32,
44
+ });
45
+ const crypto = globalThis.crypto;
46
+ if (!crypto?.subtle) {
47
+ throw new Error('WebCrypto subtle is unavailable for AES-GCM encryption.');
48
+ }
49
+ const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['encrypt']);
50
+ const encryptedBuffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, cryptoKey, plainData);
51
+ const ciphertext = new Uint8Array(encryptedBuffer);
52
+ // Payload format: [MAGIC(6)][ITERATIONS(4)][SALT(16)][IV(12)][CIPHERTEXT+TAG]
53
+ const headerSize = MAGIC_HEADER.length + ITERATIONS_LENGTH + SALT_LENGTH + IV_LENGTH;
54
+ const result = new Uint8Array(headerSize + ciphertext.length);
55
+ result.set(MAGIC_HEADER, 0);
56
+ const view = new DataView(result.buffer, result.byteOffset, result.byteLength);
57
+ view.setUint32(MAGIC_HEADER.length, iterations, false); // big-endian
58
+ result.set(salt, MAGIC_HEADER.length + ITERATIONS_LENGTH);
59
+ result.set(iv, MAGIC_HEADER.length + ITERATIONS_LENGTH + SALT_LENGTH);
60
+ result.set(ciphertext, headerSize);
61
+ return result;
62
+ }
63
+ async function decryptBackupData(encryptedData, passphrase, options = {}) {
64
+ if (!passphrase || !passphrase.trim()) {
65
+ throw new Error('A non-empty passphrase is required for backup decryption.');
66
+ }
67
+ if (!isEncryptedBackup(encryptedData)) {
68
+ throw new Error('Invalid backup file format: missing LLENC1 encryption header.');
69
+ }
70
+ const view = new DataView(encryptedData.buffer, encryptedData.byteOffset, encryptedData.byteLength);
71
+ const storedIterations = view.getUint32(MAGIC_HEADER.length, false);
72
+ const iterations = options.iterations ?? (storedIterations > 0 ? storedIterations : DEFAULT_ITERATIONS);
73
+ const saltOffset = MAGIC_HEADER.length + ITERATIONS_LENGTH;
74
+ const ivOffset = saltOffset + SALT_LENGTH;
75
+ const ciphertextOffset = ivOffset + IV_LENGTH;
76
+ const salt = encryptedData.slice(saltOffset, ivOffset);
77
+ const iv = encryptedData.slice(ivOffset, ciphertextOffset);
78
+ const ciphertext = encryptedData.slice(ciphertextOffset);
79
+ const keyBytes = (0, pbkdf2_js_1.pbkdf2)(sha2_js_1.sha256, (0, utils_js_1.utf8ToBytes)(passphrase), salt, {
80
+ c: iterations,
81
+ dkLen: 32,
82
+ });
83
+ const crypto = globalThis.crypto;
84
+ if (!crypto?.subtle) {
85
+ throw new Error('WebCrypto subtle is unavailable for AES-GCM decryption.');
86
+ }
87
+ const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['decrypt']);
88
+ try {
89
+ const decryptedBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, cryptoKey, ciphertext);
90
+ return new Uint8Array(decryptedBuffer);
91
+ }
92
+ catch {
93
+ throw new Error('Backup decryption failed: Incorrect passphrase or corrupted database backup.');
94
+ }
95
+ }
96
+ async function createEncryptedDatabaseBackup(database, storage, options) {
97
+ const fileName = options.fileName ??
98
+ `local-lite-enc-${new Date().toISOString().replace(/[:.]/g, '-')}.enc.db`;
99
+ if (!fileName || /[\\/]/.test(fileName)) {
100
+ throw new Error('Backup file name must be a single file name.');
101
+ }
102
+ const plainBytes = await database.exportDatabase();
103
+ if (plainBytes.length === 0) {
104
+ throw new Error('Cannot create a backup from an empty database.');
105
+ }
106
+ const encryptedBytes = await encryptBackupData(plainBytes, options.passphrase, {
107
+ iterations: options.iterations,
108
+ });
109
+ await storage.save(fileName, encryptedBytes);
110
+ return fileName;
111
+ }
112
+ async function restoreEncryptedDatabaseBackup(database, storage, fileName, options) {
113
+ if (!fileName || /[\\/]/.test(fileName)) {
114
+ throw new Error('Backup file name must be a single file name.');
115
+ }
116
+ const rawBytes = await storage.read(fileName);
117
+ if (rawBytes.length === 0) {
118
+ throw new Error('Cannot restore an empty database backup.');
119
+ }
120
+ const decryptedBytes = await decryptBackupData(rawBytes, options.passphrase);
121
+ await database.importDatabase(decryptedBytes);
122
+ }
@@ -1,4 +1,6 @@
1
1
  export { type BackupAccessTokenProvider, type CloudBackupFile, type GoogleDriveAuthorizationRequest, type GoogleDriveAuthorizationResponse, type GoogleDriveBackupClientOptions, type GoogleDriveKeyValueStorage, type GoogleDriveTokenStore, type GoogleDriveTokens, GoogleDriveBackupClient, GoogleDriveBackupStorage, createGoogleDriveTokenStore, } from './cloud-storage.js';
2
+ export { MICROSOFT_GRAPH_DRIVE, OneDriveBackupStorage, OneDriveBackupClient, } from './onedrive.js';
3
+ export { type EncryptedBackupOptions, type RestoreEncryptedBackupOptions, isEncryptedBackup, encryptBackupData, decryptBackupData, createEncryptedDatabaseBackup, restoreEncryptedDatabaseBackup, } from './encryption.js';
2
4
  export interface BackupDatabase {
3
5
  exportDatabase(): Promise<Uint8Array>;
4
6
  importDatabase(data: Uint8Array): Promise<void>;
@@ -1,12 +1,22 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.createGoogleDriveTokenStore = exports.GoogleDriveBackupStorage = exports.GoogleDriveBackupClient = void 0;
3
+ exports.restoreEncryptedDatabaseBackup = exports.createEncryptedDatabaseBackup = exports.decryptBackupData = exports.encryptBackupData = exports.isEncryptedBackup = exports.OneDriveBackupClient = exports.OneDriveBackupStorage = exports.MICROSOFT_GRAPH_DRIVE = exports.createGoogleDriveTokenStore = exports.GoogleDriveBackupStorage = exports.GoogleDriveBackupClient = void 0;
4
4
  exports.createDatabaseBackup = createDatabaseBackup;
5
5
  exports.restoreDatabaseBackup = restoreDatabaseBackup;
6
6
  var cloud_storage_js_1 = require("./cloud-storage.js");
7
7
  Object.defineProperty(exports, "GoogleDriveBackupClient", { enumerable: true, get: function () { return cloud_storage_js_1.GoogleDriveBackupClient; } });
8
8
  Object.defineProperty(exports, "GoogleDriveBackupStorage", { enumerable: true, get: function () { return cloud_storage_js_1.GoogleDriveBackupStorage; } });
9
9
  Object.defineProperty(exports, "createGoogleDriveTokenStore", { enumerable: true, get: function () { return cloud_storage_js_1.createGoogleDriveTokenStore; } });
10
+ var onedrive_js_1 = require("./onedrive.js");
11
+ Object.defineProperty(exports, "MICROSOFT_GRAPH_DRIVE", { enumerable: true, get: function () { return onedrive_js_1.MICROSOFT_GRAPH_DRIVE; } });
12
+ Object.defineProperty(exports, "OneDriveBackupStorage", { enumerable: true, get: function () { return onedrive_js_1.OneDriveBackupStorage; } });
13
+ Object.defineProperty(exports, "OneDriveBackupClient", { enumerable: true, get: function () { return onedrive_js_1.OneDriveBackupClient; } });
14
+ var encryption_js_1 = require("./encryption.js");
15
+ Object.defineProperty(exports, "isEncryptedBackup", { enumerable: true, get: function () { return encryption_js_1.isEncryptedBackup; } });
16
+ Object.defineProperty(exports, "encryptBackupData", { enumerable: true, get: function () { return encryption_js_1.encryptBackupData; } });
17
+ Object.defineProperty(exports, "decryptBackupData", { enumerable: true, get: function () { return encryption_js_1.decryptBackupData; } });
18
+ Object.defineProperty(exports, "createEncryptedDatabaseBackup", { enumerable: true, get: function () { return encryption_js_1.createEncryptedDatabaseBackup; } });
19
+ Object.defineProperty(exports, "restoreEncryptedDatabaseBackup", { enumerable: true, get: function () { return encryption_js_1.restoreEncryptedDatabaseBackup; } });
10
20
  function validateFileName(fileName) {
11
21
  if (!fileName || /[\\/]/.test(fileName)) {
12
22
  throw new Error('Backup file name must be a single file name.');
@@ -0,0 +1,22 @@
1
+ import type { BackupAccessTokenProvider, BackupStorage, CloudBackupFile } from './index.js';
2
+ import { type MicrosoftAuthClientOptions } from '../auth/microsoft.js';
3
+ export declare const MICROSOFT_GRAPH_DRIVE = "https://graph.microsoft.com/v1.0/me/drive";
4
+ export declare class OneDriveBackupStorage implements BackupStorage {
5
+ private readonly getAccessToken;
6
+ private readonly fetcher;
7
+ constructor(getAccessToken: BackupAccessTokenProvider, fetcher?: typeof fetch);
8
+ save(fileName: string, data: Uint8Array): Promise<void>;
9
+ read(fileIdOrName: string): Promise<Uint8Array>;
10
+ list(): Promise<CloudBackupFile[]>;
11
+ }
12
+ export declare class OneDriveBackupClient implements BackupStorage {
13
+ private readonly storage;
14
+ private readonly authClient;
15
+ constructor(options: MicrosoftAuthClientOptions);
16
+ connect(): Promise<boolean>;
17
+ disconnect(): Promise<void>;
18
+ isConnected(): Promise<boolean>;
19
+ save(fileName: string, data: Uint8Array): Promise<void>;
20
+ read(fileId: string): Promise<Uint8Array>;
21
+ list(): Promise<CloudBackupFile[]>;
22
+ }
@@ -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;
@@ -2,6 +2,7 @@ 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';
@@ -78,6 +79,7 @@ export declare class LocalFirstClient {
78
79
  autoHydrateJSON: boolean;
79
80
  /** Local column stamped on INSERT/UPDATE writes, or null when disabled. */
80
81
  touchColumn: string | null;
82
+ readonly maintenance: DatabaseMaintenance;
81
83
  private primaryKey;
82
84
  private migrations;
83
85
  private autoStart;
@@ -6,6 +6,7 @@ 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");
@@ -156,6 +157,7 @@ class LocalFirstClient {
156
157
  autoHydrateJSON;
157
158
  /** Local column stamped on INSERT/UPDATE writes, or null when disabled. */
158
159
  touchColumn;
160
+ maintenance;
159
161
  primaryKey;
160
162
  migrations;
161
163
  autoStart;
@@ -180,6 +182,7 @@ class LocalFirstClient {
180
182
  ? touch
181
183
  : this.remote?.updatedAtColumn || 'updated_at';
182
184
  this.schemaManager = new schema_manager_js_1.SchemaManager();
185
+ this.maintenance = new maintenance_js_1.DatabaseMaintenance(this.db, () => this.tables);
183
186
  const workerOptions = {
184
187
  network: config.network,
185
188
  conflictResolver: config.conflictResolver,
@@ -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;
@@ -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;
@@ -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
+ }