jotai-state-tree 1.8.0 → 1.9.1
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/dist/{chunk-MGW4FLIA.mjs → chunk-MLAEAO2W.mjs} +689 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +690 -0
- package/dist/index.mjs +5 -1
- package/dist/{router-9U4hkUrl.d.mts → persistence-BLu5h3-2.d.mts} +86 -1
- package/dist/{router-9U4hkUrl.d.ts → persistence-BLu5h3-2.d.ts} +86 -1
- package/dist/react.d.mts +18 -3
- package/dist/react.d.ts +18 -3
- package/dist/react.js +727 -19
- package/dist/react.mjs +21 -1
- package/package.json +2 -1
- package/src/__tests__/examples/offline-sync-persistence.test.tsx +261 -0
- package/src/__tests__/persistence.test.ts +624 -0
- package/src/index.ts +17 -0
- package/src/persistence.ts +926 -0
- package/src/react.ts +51 -0
|
@@ -3900,6 +3900,692 @@ function useRouter() {
|
|
|
3900
3900
|
return router;
|
|
3901
3901
|
}
|
|
3902
3902
|
|
|
3903
|
+
// src/persistence.ts
|
|
3904
|
+
import { atom as atom4 } from "jotai";
|
|
3905
|
+
var workerCode = `
|
|
3906
|
+
self.onmessage = function(e) {
|
|
3907
|
+
const { key, dbName, currentSnapshot } = e.data;
|
|
3908
|
+
|
|
3909
|
+
function applyPatch(obj, patch) {
|
|
3910
|
+
if (patch.path === "" || patch.path === "/") {
|
|
3911
|
+
if (patch.op === "replace") {
|
|
3912
|
+
return JSON.parse(JSON.stringify(patch.value));
|
|
3913
|
+
}
|
|
3914
|
+
return obj;
|
|
3915
|
+
}
|
|
3916
|
+
|
|
3917
|
+
const parts = patch.path.split("/").filter(Boolean);
|
|
3918
|
+
let current = obj;
|
|
3919
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
3920
|
+
const part = parts[i];
|
|
3921
|
+
const index = isNaN(part) ? part : Number(part);
|
|
3922
|
+
if (current === undefined || current === null) return obj;
|
|
3923
|
+
current = current[index];
|
|
3924
|
+
}
|
|
3925
|
+
if (current === undefined || current === null) return obj;
|
|
3926
|
+
|
|
3927
|
+
const lastPart = parts[parts.length - 1];
|
|
3928
|
+
const lastKey = isNaN(lastPart) ? lastPart : Number(lastPart);
|
|
3929
|
+
|
|
3930
|
+
if (patch.op === "replace" || patch.op === "add") {
|
|
3931
|
+
const val = patch.value !== undefined ? JSON.parse(JSON.stringify(patch.value)) : undefined;
|
|
3932
|
+
if (patch.op === "replace") {
|
|
3933
|
+
current[lastKey] = val;
|
|
3934
|
+
} else {
|
|
3935
|
+
if (Array.isArray(current)) {
|
|
3936
|
+
current.splice(lastKey, 0, val);
|
|
3937
|
+
} else {
|
|
3938
|
+
current[lastKey] = val;
|
|
3939
|
+
}
|
|
3940
|
+
}
|
|
3941
|
+
} else if (patch.op === "remove") {
|
|
3942
|
+
if (Array.isArray(current)) {
|
|
3943
|
+
current.splice(lastKey, 1);
|
|
3944
|
+
} else {
|
|
3945
|
+
delete current[lastKey];
|
|
3946
|
+
}
|
|
3947
|
+
}
|
|
3948
|
+
return obj;
|
|
3949
|
+
}
|
|
3950
|
+
|
|
3951
|
+
const request = indexedDB.open(dbName, 1);
|
|
3952
|
+
request.onerror = function() {
|
|
3953
|
+
self.postMessage({ error: "Failed to open IndexedDB in worker" });
|
|
3954
|
+
};
|
|
3955
|
+
request.onsuccess = function() {
|
|
3956
|
+
const db = request.result;
|
|
3957
|
+
const tx = db.transaction("sync_queue", "readwrite");
|
|
3958
|
+
const store = tx.objectStore("sync_queue");
|
|
3959
|
+
const getReq = store.getAll();
|
|
3960
|
+
|
|
3961
|
+
getReq.onerror = function() {
|
|
3962
|
+
db.close();
|
|
3963
|
+
self.postMessage({ error: "Failed to get queue from store in worker" });
|
|
3964
|
+
};
|
|
3965
|
+
|
|
3966
|
+
getReq.onsuccess = function() {
|
|
3967
|
+
const allItems = getReq.result || [];
|
|
3968
|
+
const queue = allItems.filter(item => item.key === key);
|
|
3969
|
+
|
|
3970
|
+
if (queue.length <= 1) {
|
|
3971
|
+
db.close();
|
|
3972
|
+
self.postMessage({ success: true, key, compacted: false });
|
|
3973
|
+
return;
|
|
3974
|
+
}
|
|
3975
|
+
|
|
3976
|
+
// Reconstruct initial snapshot by applying inverse patches in reverse order
|
|
3977
|
+
let initialSnapshot = JSON.parse(JSON.stringify(currentSnapshot));
|
|
3978
|
+
for (let i = queue.length - 1; i >= 0; i--) {
|
|
3979
|
+
const item = queue[i];
|
|
3980
|
+
for (let j = item.inversePatches.length - 1; j >= 0; j--) {
|
|
3981
|
+
initialSnapshot = applyPatch(initialSnapshot, item.inversePatches[j]);
|
|
3982
|
+
}
|
|
3983
|
+
}
|
|
3984
|
+
|
|
3985
|
+
// Delete old queue items
|
|
3986
|
+
let deleteCount = 0;
|
|
3987
|
+
let hasError = false;
|
|
3988
|
+
|
|
3989
|
+
function checkDone() {
|
|
3990
|
+
if (hasError) return;
|
|
3991
|
+
if (deleteCount === queue.length) {
|
|
3992
|
+
// Add consolidated item
|
|
3993
|
+
const addReq = store.add({
|
|
3994
|
+
key: key,
|
|
3995
|
+
patches: [{ op: "replace", path: "", value: currentSnapshot }],
|
|
3996
|
+
inversePatches: [{ op: "replace", path: "", value: initialSnapshot }],
|
|
3997
|
+
timestamp: Date.now()
|
|
3998
|
+
});
|
|
3999
|
+
|
|
4000
|
+
addReq.onerror = function() {
|
|
4001
|
+
db.close();
|
|
4002
|
+
self.postMessage({ error: "Failed to add consolidated mutation in worker" });
|
|
4003
|
+
};
|
|
4004
|
+
addReq.onsuccess = function() {
|
|
4005
|
+
db.close();
|
|
4006
|
+
self.postMessage({ success: true, key, compacted: true });
|
|
4007
|
+
};
|
|
4008
|
+
}
|
|
4009
|
+
}
|
|
4010
|
+
|
|
4011
|
+
for (const item of queue) {
|
|
4012
|
+
if (item.id !== undefined) {
|
|
4013
|
+
const delReq = store.delete(item.id);
|
|
4014
|
+
delReq.onerror = function() {
|
|
4015
|
+
hasError = true;
|
|
4016
|
+
db.close();
|
|
4017
|
+
self.postMessage({ error: "Failed to delete queue item in worker" });
|
|
4018
|
+
};
|
|
4019
|
+
delReq.onsuccess = function() {
|
|
4020
|
+
deleteCount++;
|
|
4021
|
+
checkDone();
|
|
4022
|
+
};
|
|
4023
|
+
}
|
|
4024
|
+
}
|
|
4025
|
+
};
|
|
4026
|
+
};
|
|
4027
|
+
};
|
|
4028
|
+
`;
|
|
4029
|
+
var IndexedDBStorage = class {
|
|
4030
|
+
constructor(dbName) {
|
|
4031
|
+
this.db = null;
|
|
4032
|
+
this.dbName = dbName;
|
|
4033
|
+
}
|
|
4034
|
+
async init() {
|
|
4035
|
+
if (this.db) return;
|
|
4036
|
+
return new Promise((resolve, reject) => {
|
|
4037
|
+
if (typeof indexedDB === "undefined") {
|
|
4038
|
+
reject(
|
|
4039
|
+
new Error(
|
|
4040
|
+
"[jotai-state-tree] IndexedDB is not supported in this environment."
|
|
4041
|
+
)
|
|
4042
|
+
);
|
|
4043
|
+
return;
|
|
4044
|
+
}
|
|
4045
|
+
const request = indexedDB.open(this.dbName, 1);
|
|
4046
|
+
request.onupgradeneeded = () => {
|
|
4047
|
+
const db = request.result;
|
|
4048
|
+
if (!db.objectStoreNames.contains("snapshots")) {
|
|
4049
|
+
db.createObjectStore("snapshots");
|
|
4050
|
+
}
|
|
4051
|
+
if (!db.objectStoreNames.contains("sync_queue")) {
|
|
4052
|
+
db.createObjectStore("sync_queue", {
|
|
4053
|
+
keyPath: "id",
|
|
4054
|
+
autoIncrement: true
|
|
4055
|
+
});
|
|
4056
|
+
}
|
|
4057
|
+
};
|
|
4058
|
+
request.onsuccess = () => {
|
|
4059
|
+
this.db = request.result;
|
|
4060
|
+
resolve();
|
|
4061
|
+
};
|
|
4062
|
+
request.onerror = () => {
|
|
4063
|
+
reject(request.error);
|
|
4064
|
+
};
|
|
4065
|
+
});
|
|
4066
|
+
}
|
|
4067
|
+
async getSnapshot(key) {
|
|
4068
|
+
await this.init();
|
|
4069
|
+
return new Promise((resolve, reject) => {
|
|
4070
|
+
const tx = this.db.transaction("snapshots", "readonly");
|
|
4071
|
+
const store = tx.objectStore("snapshots");
|
|
4072
|
+
const req = store.get(key);
|
|
4073
|
+
req.onsuccess = () => resolve(req.result);
|
|
4074
|
+
req.onerror = () => reject(req.error);
|
|
4075
|
+
});
|
|
4076
|
+
}
|
|
4077
|
+
async setSnapshot(key, value) {
|
|
4078
|
+
await this.init();
|
|
4079
|
+
return new Promise((resolve, reject) => {
|
|
4080
|
+
const tx = this.db.transaction("snapshots", "readwrite");
|
|
4081
|
+
const store = tx.objectStore("snapshots");
|
|
4082
|
+
const req = store.put(value, key);
|
|
4083
|
+
req.onsuccess = () => resolve();
|
|
4084
|
+
req.onerror = () => reject(req.error);
|
|
4085
|
+
});
|
|
4086
|
+
}
|
|
4087
|
+
async clearSnapshots() {
|
|
4088
|
+
await this.init();
|
|
4089
|
+
return new Promise((resolve, reject) => {
|
|
4090
|
+
const tx = this.db.transaction("snapshots", "readwrite");
|
|
4091
|
+
const store = tx.objectStore("snapshots");
|
|
4092
|
+
const req = store.clear();
|
|
4093
|
+
req.onsuccess = () => resolve();
|
|
4094
|
+
req.onerror = () => reject(req.error);
|
|
4095
|
+
});
|
|
4096
|
+
}
|
|
4097
|
+
async getQueue(key) {
|
|
4098
|
+
await this.init();
|
|
4099
|
+
return new Promise((resolve, reject) => {
|
|
4100
|
+
const tx = this.db.transaction("sync_queue", "readonly");
|
|
4101
|
+
const store = tx.objectStore("sync_queue");
|
|
4102
|
+
const req = store.getAll();
|
|
4103
|
+
req.onsuccess = () => {
|
|
4104
|
+
const results = req.result.filter(
|
|
4105
|
+
(item) => item.key === key
|
|
4106
|
+
);
|
|
4107
|
+
resolve(results);
|
|
4108
|
+
};
|
|
4109
|
+
req.onerror = () => reject(req.error);
|
|
4110
|
+
});
|
|
4111
|
+
}
|
|
4112
|
+
async addQueue(item) {
|
|
4113
|
+
await this.init();
|
|
4114
|
+
return new Promise((resolve, reject) => {
|
|
4115
|
+
const tx = this.db.transaction("sync_queue", "readwrite");
|
|
4116
|
+
const store = tx.objectStore("sync_queue");
|
|
4117
|
+
const req = store.add(item);
|
|
4118
|
+
req.onsuccess = () => resolve(req.result);
|
|
4119
|
+
req.onerror = () => reject(req.error);
|
|
4120
|
+
});
|
|
4121
|
+
}
|
|
4122
|
+
async deleteQueue(id) {
|
|
4123
|
+
await this.init();
|
|
4124
|
+
return new Promise((resolve, reject) => {
|
|
4125
|
+
const tx = this.db.transaction("sync_queue", "readwrite");
|
|
4126
|
+
const store = tx.objectStore("sync_queue");
|
|
4127
|
+
const req = store.delete(id);
|
|
4128
|
+
req.onsuccess = () => resolve();
|
|
4129
|
+
req.onerror = () => reject(req.error);
|
|
4130
|
+
});
|
|
4131
|
+
}
|
|
4132
|
+
async clearQueue(key) {
|
|
4133
|
+
await this.init();
|
|
4134
|
+
const items = await this.getQueue(key);
|
|
4135
|
+
if (items.length === 0) return;
|
|
4136
|
+
return new Promise((resolve, reject) => {
|
|
4137
|
+
const tx = this.db.transaction("sync_queue", "readwrite");
|
|
4138
|
+
const store = tx.objectStore("sync_queue");
|
|
4139
|
+
let completed = 0;
|
|
4140
|
+
let hasError = false;
|
|
4141
|
+
for (const item of items) {
|
|
4142
|
+
if (item.id !== void 0) {
|
|
4143
|
+
const req = store.delete(item.id);
|
|
4144
|
+
req.onsuccess = () => {
|
|
4145
|
+
completed++;
|
|
4146
|
+
if (completed === items.length && !hasError) {
|
|
4147
|
+
resolve();
|
|
4148
|
+
}
|
|
4149
|
+
};
|
|
4150
|
+
req.onerror = () => {
|
|
4151
|
+
hasError = true;
|
|
4152
|
+
reject(req.error);
|
|
4153
|
+
};
|
|
4154
|
+
}
|
|
4155
|
+
}
|
|
4156
|
+
});
|
|
4157
|
+
}
|
|
4158
|
+
};
|
|
4159
|
+
function defaultShouldRollback(error) {
|
|
4160
|
+
if (typeof navigator !== "undefined" && !navigator.onLine) {
|
|
4161
|
+
return false;
|
|
4162
|
+
}
|
|
4163
|
+
const errMsg = String(error.message || error).toLowerCase();
|
|
4164
|
+
if (errMsg.includes("network") || errMsg.includes("fetch") || errMsg.includes("timeout") || errMsg.includes("abort") || errMsg.includes("failed to fetch")) {
|
|
4165
|
+
return false;
|
|
4166
|
+
}
|
|
4167
|
+
return true;
|
|
4168
|
+
}
|
|
4169
|
+
var PersistenceManager = class {
|
|
4170
|
+
constructor(target, options = {}) {
|
|
4171
|
+
this.key = "root";
|
|
4172
|
+
this.patchDisposer = null;
|
|
4173
|
+
this.skipSyncing = false;
|
|
4174
|
+
this.lastFetchedTime = 0;
|
|
4175
|
+
this.fetchTimeout = null;
|
|
4176
|
+
// Optimistic & High-Performance Batching State
|
|
4177
|
+
this.pendingPatches = [];
|
|
4178
|
+
this.pendingInversePatches = [];
|
|
4179
|
+
this.batchScheduled = false;
|
|
4180
|
+
this.debounceSnapshotTimeout = null;
|
|
4181
|
+
this.lastSnapshotToWrite = null;
|
|
4182
|
+
// Window Focus and Reconnect Listeners
|
|
4183
|
+
this.focusListener = null;
|
|
4184
|
+
this.onlineListener = null;
|
|
4185
|
+
this.offlineListener = null;
|
|
4186
|
+
this.target = target;
|
|
4187
|
+
this.options = options;
|
|
4188
|
+
if (typeof indexedDB === "undefined") {
|
|
4189
|
+
throw new Error(
|
|
4190
|
+
"[jotai-state-tree] IndexedDB is not supported in this environment."
|
|
4191
|
+
);
|
|
4192
|
+
}
|
|
4193
|
+
if (options.key) {
|
|
4194
|
+
this.key = options.key;
|
|
4195
|
+
} else {
|
|
4196
|
+
const node = getStateTreeNode(target);
|
|
4197
|
+
const idAttribute = node.$type.identifierAttribute;
|
|
4198
|
+
if (idAttribute) {
|
|
4199
|
+
const idValue = target[idAttribute];
|
|
4200
|
+
if (idValue !== void 0 && idValue !== null) {
|
|
4201
|
+
this.key = String(idValue);
|
|
4202
|
+
}
|
|
4203
|
+
}
|
|
4204
|
+
}
|
|
4205
|
+
const dbName = options.dbName ?? "jotai-state-tree-persistence";
|
|
4206
|
+
this.storage = new IndexedDBStorage(dbName);
|
|
4207
|
+
const initialOffline = typeof navigator !== "undefined" ? !navigator.onLine : false;
|
|
4208
|
+
this.statusAtom = atom4({
|
|
4209
|
+
isLoading: true,
|
|
4210
|
+
isFetching: false,
|
|
4211
|
+
isSyncing: false,
|
|
4212
|
+
isOffline: initialOffline,
|
|
4213
|
+
pendingSyncCount: 0,
|
|
4214
|
+
error: null
|
|
4215
|
+
});
|
|
4216
|
+
}
|
|
4217
|
+
async initialize() {
|
|
4218
|
+
const store = getGlobalStore();
|
|
4219
|
+
const cachedSnapshot = await this.storage.getSnapshot(this.key);
|
|
4220
|
+
let queue = await this.storage.getQueue(this.key);
|
|
4221
|
+
if (cachedSnapshot !== void 0 && cachedSnapshot !== null) {
|
|
4222
|
+
this.skipSyncing = true;
|
|
4223
|
+
try {
|
|
4224
|
+
applySnapshot(this.target, cachedSnapshot);
|
|
4225
|
+
} finally {
|
|
4226
|
+
this.skipSyncing = false;
|
|
4227
|
+
}
|
|
4228
|
+
store.set(this.statusAtom, (prev) => ({
|
|
4229
|
+
...prev,
|
|
4230
|
+
isLoading: false
|
|
4231
|
+
}));
|
|
4232
|
+
} else {
|
|
4233
|
+
store.set(this.statusAtom, (prev) => ({
|
|
4234
|
+
...prev,
|
|
4235
|
+
isLoading: !!this.options.query?.queryFn
|
|
4236
|
+
}));
|
|
4237
|
+
}
|
|
4238
|
+
const maxQueueSize = this.options.maxQueueSize ?? 20;
|
|
4239
|
+
if (queue.length > maxQueueSize) {
|
|
4240
|
+
try {
|
|
4241
|
+
await this.compact();
|
|
4242
|
+
queue = await this.storage.getQueue(this.key);
|
|
4243
|
+
} catch (err) {
|
|
4244
|
+
console.warn("[jotai-state-tree] Initial compaction failed:", err);
|
|
4245
|
+
}
|
|
4246
|
+
}
|
|
4247
|
+
store.set(this.statusAtom, (prev) => ({
|
|
4248
|
+
...prev,
|
|
4249
|
+
pendingSyncCount: queue.length
|
|
4250
|
+
}));
|
|
4251
|
+
if (this.patchDisposer) {
|
|
4252
|
+
this.patchDisposer();
|
|
4253
|
+
}
|
|
4254
|
+
this.patchDisposer = onPatch(this.target, (patch, reversePatch) => {
|
|
4255
|
+
this.handlePatch(patch, reversePatch);
|
|
4256
|
+
});
|
|
4257
|
+
if (typeof window !== "undefined") {
|
|
4258
|
+
this.onlineListener = () => {
|
|
4259
|
+
store.set(this.statusAtom, (prev) => ({ ...prev, isOffline: false }));
|
|
4260
|
+
this.sync();
|
|
4261
|
+
if (this.options.query?.refetchOnReconnect !== false) {
|
|
4262
|
+
this.fetch();
|
|
4263
|
+
}
|
|
4264
|
+
};
|
|
4265
|
+
this.offlineListener = () => {
|
|
4266
|
+
store.set(this.statusAtom, (prev) => ({ ...prev, isOffline: true }));
|
|
4267
|
+
};
|
|
4268
|
+
window.addEventListener("online", this.onlineListener);
|
|
4269
|
+
window.addEventListener("offline", this.offlineListener);
|
|
4270
|
+
if (this.options.query?.refetchOnWindowFocus) {
|
|
4271
|
+
this.focusListener = () => {
|
|
4272
|
+
this.fetch();
|
|
4273
|
+
};
|
|
4274
|
+
window.addEventListener("focus", this.focusListener);
|
|
4275
|
+
}
|
|
4276
|
+
}
|
|
4277
|
+
if (this.options.query?.queryFn) {
|
|
4278
|
+
this.fetchTimeout = setTimeout(() => this.fetch(), 0);
|
|
4279
|
+
}
|
|
4280
|
+
if (queue.length > 0) {
|
|
4281
|
+
await this.sync();
|
|
4282
|
+
}
|
|
4283
|
+
}
|
|
4284
|
+
async fetch(force = false) {
|
|
4285
|
+
const query = this.options.query;
|
|
4286
|
+
if (!query?.queryFn) return;
|
|
4287
|
+
const store = getGlobalStore();
|
|
4288
|
+
const status = store.get(this.statusAtom);
|
|
4289
|
+
if (status.isFetching) return;
|
|
4290
|
+
if (!force && query.staleTime !== void 0) {
|
|
4291
|
+
const now = Date.now();
|
|
4292
|
+
if (now - this.lastFetchedTime < query.staleTime) {
|
|
4293
|
+
return;
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4296
|
+
store.set(this.statusAtom, (prev) => ({
|
|
4297
|
+
...prev,
|
|
4298
|
+
isFetching: true
|
|
4299
|
+
}));
|
|
4300
|
+
try {
|
|
4301
|
+
const data = await query.queryFn();
|
|
4302
|
+
if (data !== void 0 && data !== null) {
|
|
4303
|
+
this.skipSyncing = true;
|
|
4304
|
+
try {
|
|
4305
|
+
applySnapshot(this.target, data);
|
|
4306
|
+
} finally {
|
|
4307
|
+
this.skipSyncing = false;
|
|
4308
|
+
}
|
|
4309
|
+
this.lastSnapshotToWrite = data;
|
|
4310
|
+
await this.storage.setSnapshot(this.key, data);
|
|
4311
|
+
}
|
|
4312
|
+
this.lastFetchedTime = Date.now();
|
|
4313
|
+
store.set(this.statusAtom, (prev) => ({
|
|
4314
|
+
...prev,
|
|
4315
|
+
isFetching: false,
|
|
4316
|
+
isLoading: false,
|
|
4317
|
+
error: null
|
|
4318
|
+
}));
|
|
4319
|
+
} catch (err) {
|
|
4320
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
4321
|
+
store.set(this.statusAtom, (prev) => ({
|
|
4322
|
+
...prev,
|
|
4323
|
+
isFetching: false,
|
|
4324
|
+
isLoading: false,
|
|
4325
|
+
error
|
|
4326
|
+
}));
|
|
4327
|
+
throw error;
|
|
4328
|
+
}
|
|
4329
|
+
}
|
|
4330
|
+
handlePatch(patch, reversePatch) {
|
|
4331
|
+
if (this.skipSyncing) return;
|
|
4332
|
+
this.pendingPatches.push(patch);
|
|
4333
|
+
this.pendingInversePatches.push(reversePatch);
|
|
4334
|
+
if (!this.batchScheduled) {
|
|
4335
|
+
this.batchScheduled = true;
|
|
4336
|
+
Promise.resolve().then(() => {
|
|
4337
|
+
this.flushBatch();
|
|
4338
|
+
});
|
|
4339
|
+
}
|
|
4340
|
+
}
|
|
4341
|
+
async flushBatch() {
|
|
4342
|
+
this.batchScheduled = false;
|
|
4343
|
+
const patches = [...this.pendingPatches];
|
|
4344
|
+
const inversePatches = [...this.pendingInversePatches];
|
|
4345
|
+
this.pendingPatches = [];
|
|
4346
|
+
this.pendingInversePatches = [];
|
|
4347
|
+
if (patches.length === 0) return;
|
|
4348
|
+
const currentSnapshot = getSnapshot(this.target);
|
|
4349
|
+
this.lastSnapshotToWrite = currentSnapshot;
|
|
4350
|
+
if (this.debounceSnapshotTimeout) {
|
|
4351
|
+
clearTimeout(this.debounceSnapshotTimeout);
|
|
4352
|
+
}
|
|
4353
|
+
this.debounceSnapshotTimeout = setTimeout(async () => {
|
|
4354
|
+
if (this.lastSnapshotToWrite) {
|
|
4355
|
+
await this.storage.setSnapshot(this.key, this.lastSnapshotToWrite);
|
|
4356
|
+
}
|
|
4357
|
+
}, 150);
|
|
4358
|
+
if (this.options.mutation?.syncFn) {
|
|
4359
|
+
await this.storage.addQueue({
|
|
4360
|
+
key: this.key,
|
|
4361
|
+
patches,
|
|
4362
|
+
inversePatches,
|
|
4363
|
+
timestamp: Date.now()
|
|
4364
|
+
});
|
|
4365
|
+
const store = getGlobalStore();
|
|
4366
|
+
let queue = await this.storage.getQueue(this.key);
|
|
4367
|
+
const maxQueueSize = this.options.maxQueueSize ?? 20;
|
|
4368
|
+
if (queue.length > maxQueueSize) {
|
|
4369
|
+
try {
|
|
4370
|
+
await this.compact();
|
|
4371
|
+
queue = await this.storage.getQueue(this.key);
|
|
4372
|
+
} catch (err) {
|
|
4373
|
+
console.warn("[jotai-state-tree] Auto-compaction failed:", err);
|
|
4374
|
+
}
|
|
4375
|
+
}
|
|
4376
|
+
store.set(this.statusAtom, (prev) => ({
|
|
4377
|
+
...prev,
|
|
4378
|
+
pendingSyncCount: queue.length
|
|
4379
|
+
}));
|
|
4380
|
+
try {
|
|
4381
|
+
await this.sync();
|
|
4382
|
+
} catch (err) {
|
|
4383
|
+
}
|
|
4384
|
+
}
|
|
4385
|
+
}
|
|
4386
|
+
/**
|
|
4387
|
+
* Compacts the queue by squashing multiple mutations into a single root replacement patch.
|
|
4388
|
+
* Runs in a background Web Worker if supported, falling back to main-thread execution.
|
|
4389
|
+
*/
|
|
4390
|
+
async compact() {
|
|
4391
|
+
const store = getGlobalStore();
|
|
4392
|
+
const currentSnapshot = getSnapshot(this.target);
|
|
4393
|
+
const dbName = this.options.dbName ?? "jotai-state-tree-persistence";
|
|
4394
|
+
const isWorkerSupported = typeof Worker !== "undefined" && typeof Blob !== "undefined" && typeof URL !== "undefined";
|
|
4395
|
+
if (isWorkerSupported) {
|
|
4396
|
+
return new Promise((resolve, reject) => {
|
|
4397
|
+
try {
|
|
4398
|
+
const blob = new Blob([workerCode], {
|
|
4399
|
+
type: "application/javascript"
|
|
4400
|
+
});
|
|
4401
|
+
const workerUrl = URL.createObjectURL(blob);
|
|
4402
|
+
const worker = new Worker(workerUrl);
|
|
4403
|
+
worker.onmessage = async (e) => {
|
|
4404
|
+
worker.terminate();
|
|
4405
|
+
URL.revokeObjectURL(workerUrl);
|
|
4406
|
+
if (e.data.error) {
|
|
4407
|
+
reject(new Error(e.data.error));
|
|
4408
|
+
} else {
|
|
4409
|
+
const queue = await this.storage.getQueue(this.key);
|
|
4410
|
+
store.set(this.statusAtom, (prev) => ({
|
|
4411
|
+
...prev,
|
|
4412
|
+
pendingSyncCount: queue.length
|
|
4413
|
+
}));
|
|
4414
|
+
resolve();
|
|
4415
|
+
}
|
|
4416
|
+
};
|
|
4417
|
+
worker.onerror = (err) => {
|
|
4418
|
+
worker.terminate();
|
|
4419
|
+
URL.revokeObjectURL(workerUrl);
|
|
4420
|
+
reject(err);
|
|
4421
|
+
};
|
|
4422
|
+
worker.postMessage({
|
|
4423
|
+
key: this.key,
|
|
4424
|
+
dbName,
|
|
4425
|
+
currentSnapshot
|
|
4426
|
+
});
|
|
4427
|
+
} catch (err) {
|
|
4428
|
+
reject(err);
|
|
4429
|
+
}
|
|
4430
|
+
});
|
|
4431
|
+
} else {
|
|
4432
|
+
const queue = await this.storage.getQueue(this.key);
|
|
4433
|
+
if (queue.length <= 1) return;
|
|
4434
|
+
this.skipSyncing = true;
|
|
4435
|
+
try {
|
|
4436
|
+
for (let i = queue.length - 1; i >= 0; i--) {
|
|
4437
|
+
const item = queue[i];
|
|
4438
|
+
for (let j = item.inversePatches.length - 1; j >= 0; j--) {
|
|
4439
|
+
applyPatch(this.target, item.inversePatches[j]);
|
|
4440
|
+
}
|
|
4441
|
+
}
|
|
4442
|
+
} finally {
|
|
4443
|
+
this.skipSyncing = false;
|
|
4444
|
+
}
|
|
4445
|
+
const initialSnapshot = getSnapshot(this.target);
|
|
4446
|
+
this.skipSyncing = true;
|
|
4447
|
+
try {
|
|
4448
|
+
applySnapshot(this.target, currentSnapshot);
|
|
4449
|
+
} finally {
|
|
4450
|
+
this.skipSyncing = false;
|
|
4451
|
+
}
|
|
4452
|
+
await this.storage.clearQueue(this.key);
|
|
4453
|
+
await this.storage.addQueue({
|
|
4454
|
+
key: this.key,
|
|
4455
|
+
patches: [{ op: "replace", path: "", value: currentSnapshot }],
|
|
4456
|
+
inversePatches: [{ op: "replace", path: "", value: initialSnapshot }],
|
|
4457
|
+
timestamp: Date.now()
|
|
4458
|
+
});
|
|
4459
|
+
const newQueue = await this.storage.getQueue(this.key);
|
|
4460
|
+
store.set(this.statusAtom, (prev) => ({
|
|
4461
|
+
...prev,
|
|
4462
|
+
pendingSyncCount: newQueue.length
|
|
4463
|
+
}));
|
|
4464
|
+
}
|
|
4465
|
+
}
|
|
4466
|
+
async sync() {
|
|
4467
|
+
const mutation = this.options.mutation;
|
|
4468
|
+
if (!mutation?.syncFn) return;
|
|
4469
|
+
const store = getGlobalStore();
|
|
4470
|
+
const status = store.get(this.statusAtom);
|
|
4471
|
+
if (status.isSyncing || status.isOffline) return;
|
|
4472
|
+
store.set(this.statusAtom, (prev) => ({ ...prev, isSyncing: true }));
|
|
4473
|
+
try {
|
|
4474
|
+
let queue = await this.storage.getQueue(this.key);
|
|
4475
|
+
while (queue.length > 0) {
|
|
4476
|
+
if (typeof navigator !== "undefined" && !navigator.onLine) {
|
|
4477
|
+
store.set(this.statusAtom, (prev) => ({ ...prev, isOffline: true }));
|
|
4478
|
+
break;
|
|
4479
|
+
}
|
|
4480
|
+
const item = queue[0];
|
|
4481
|
+
try {
|
|
4482
|
+
const currentSnapshot = getSnapshot(this.target);
|
|
4483
|
+
const syncResult = await mutation.syncFn(
|
|
4484
|
+
currentSnapshot,
|
|
4485
|
+
item.patches
|
|
4486
|
+
);
|
|
4487
|
+
if (item.id !== void 0) {
|
|
4488
|
+
await this.storage.deleteQueue(item.id);
|
|
4489
|
+
}
|
|
4490
|
+
if (mutation.onSuccess) {
|
|
4491
|
+
mutation.onSuccess(syncResult);
|
|
4492
|
+
}
|
|
4493
|
+
} catch (err) {
|
|
4494
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
4495
|
+
const shouldRollback = mutation.shouldRollback ?? defaultShouldRollback;
|
|
4496
|
+
if (shouldRollback(error)) {
|
|
4497
|
+
this.skipSyncing = true;
|
|
4498
|
+
try {
|
|
4499
|
+
for (let i = item.inversePatches.length - 1; i >= 0; i--) {
|
|
4500
|
+
applyPatch(this.target, item.inversePatches[i]);
|
|
4501
|
+
}
|
|
4502
|
+
const currentSnapshot = getSnapshot(this.target);
|
|
4503
|
+
this.lastSnapshotToWrite = currentSnapshot;
|
|
4504
|
+
await this.storage.setSnapshot(this.key, currentSnapshot);
|
|
4505
|
+
} finally {
|
|
4506
|
+
this.skipSyncing = false;
|
|
4507
|
+
}
|
|
4508
|
+
if (item.id !== void 0) {
|
|
4509
|
+
await this.storage.deleteQueue(item.id);
|
|
4510
|
+
}
|
|
4511
|
+
if (mutation.onError) {
|
|
4512
|
+
mutation.onError(error);
|
|
4513
|
+
}
|
|
4514
|
+
throw error;
|
|
4515
|
+
} else {
|
|
4516
|
+
store.set(this.statusAtom, (prev) => ({ ...prev, error }));
|
|
4517
|
+
if (mutation.onError) {
|
|
4518
|
+
mutation.onError(error);
|
|
4519
|
+
}
|
|
4520
|
+
break;
|
|
4521
|
+
}
|
|
4522
|
+
}
|
|
4523
|
+
queue = await this.storage.getQueue(this.key);
|
|
4524
|
+
store.set(this.statusAtom, (prev) => ({
|
|
4525
|
+
...prev,
|
|
4526
|
+
pendingSyncCount: queue.length
|
|
4527
|
+
}));
|
|
4528
|
+
}
|
|
4529
|
+
const finalQueue = await this.storage.getQueue(this.key);
|
|
4530
|
+
store.set(this.statusAtom, (prev) => ({
|
|
4531
|
+
...prev,
|
|
4532
|
+
isSyncing: false,
|
|
4533
|
+
pendingSyncCount: finalQueue.length,
|
|
4534
|
+
error: finalQueue.length === 0 ? null : prev.error
|
|
4535
|
+
}));
|
|
4536
|
+
} catch (err) {
|
|
4537
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
4538
|
+
store.set(this.statusAtom, (prev) => ({
|
|
4539
|
+
...prev,
|
|
4540
|
+
isSyncing: false,
|
|
4541
|
+
error
|
|
4542
|
+
}));
|
|
4543
|
+
throw error;
|
|
4544
|
+
}
|
|
4545
|
+
}
|
|
4546
|
+
async clear() {
|
|
4547
|
+
await this.storage.clearSnapshots();
|
|
4548
|
+
await this.storage.clearQueue(this.key);
|
|
4549
|
+
const store = getGlobalStore();
|
|
4550
|
+
store.set(this.statusAtom, (prev) => ({
|
|
4551
|
+
...prev,
|
|
4552
|
+
pendingSyncCount: 0,
|
|
4553
|
+
error: null
|
|
4554
|
+
}));
|
|
4555
|
+
}
|
|
4556
|
+
dispose() {
|
|
4557
|
+
if (this.fetchTimeout) {
|
|
4558
|
+
clearTimeout(this.fetchTimeout);
|
|
4559
|
+
this.fetchTimeout = null;
|
|
4560
|
+
}
|
|
4561
|
+
if (this.debounceSnapshotTimeout) {
|
|
4562
|
+
clearTimeout(this.debounceSnapshotTimeout);
|
|
4563
|
+
this.debounceSnapshotTimeout = null;
|
|
4564
|
+
}
|
|
4565
|
+
if (this.patchDisposer) {
|
|
4566
|
+
this.patchDisposer();
|
|
4567
|
+
this.patchDisposer = null;
|
|
4568
|
+
}
|
|
4569
|
+
if (typeof window !== "undefined") {
|
|
4570
|
+
if (this.onlineListener) {
|
|
4571
|
+
window.removeEventListener("online", this.onlineListener);
|
|
4572
|
+
this.onlineListener = null;
|
|
4573
|
+
}
|
|
4574
|
+
if (this.offlineListener) {
|
|
4575
|
+
window.removeEventListener("offline", this.offlineListener);
|
|
4576
|
+
this.offlineListener = null;
|
|
4577
|
+
}
|
|
4578
|
+
if (this.focusListener) {
|
|
4579
|
+
window.removeEventListener("focus", this.focusListener);
|
|
4580
|
+
this.focusListener = null;
|
|
4581
|
+
}
|
|
4582
|
+
}
|
|
4583
|
+
}
|
|
4584
|
+
};
|
|
4585
|
+
function createPersistenceManager(target, options) {
|
|
4586
|
+
return new PersistenceManager(target, options);
|
|
4587
|
+
}
|
|
4588
|
+
|
|
3903
4589
|
export {
|
|
3904
4590
|
string,
|
|
3905
4591
|
number,
|
|
@@ -4005,5 +4691,7 @@ export {
|
|
|
4005
4691
|
RouterModel,
|
|
4006
4692
|
createRouter,
|
|
4007
4693
|
RouterContext,
|
|
4008
|
-
useRouter
|
|
4694
|
+
useRouter,
|
|
4695
|
+
PersistenceManager,
|
|
4696
|
+
createPersistenceManager
|
|
4009
4697
|
};
|